Compare commits
68
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efa5703337 | ||
|
|
06bed42ace | ||
|
|
f4147cef30 | ||
|
|
091cfcca96 | ||
|
|
4a37cfd2ce | ||
|
|
36db9a2ae0 | ||
|
|
78896793aa | ||
|
|
9217d1faaa | ||
|
|
be3139f97d | ||
|
|
fafc1adbfd | ||
|
|
b0ceb52bf1 | ||
|
|
861e828fe2 | ||
|
|
da07048f53 | ||
|
|
5fa3f3ec76 | ||
|
|
fb5304563b | ||
|
|
f31aeb4f6e | ||
|
|
d9110c373d | ||
|
|
518d329c29 | ||
|
|
b99a1e7443 | ||
|
|
f5bd611848 | ||
|
|
5dac80e13b | ||
|
|
19bbbab907 | ||
|
|
78e89ebcec | ||
|
|
8d39586de5 | ||
|
|
eaa92738dc | ||
|
|
b2349525fd | ||
|
|
898209725a | ||
|
|
1fe767ea77 | ||
|
|
43f32a7440 | ||
|
|
cdbf72c7fe | ||
|
|
ab2336516f | ||
|
|
9f243f569f | ||
|
|
160788780b | ||
|
|
41f54a292b | ||
|
|
ae70dd5ea8 | ||
|
|
060033322d | ||
|
|
c97da0292a | ||
|
|
d2c07a9fd2 | ||
|
|
537cc29c3e | ||
|
|
816f869535 | ||
|
|
d058cd9258 | ||
|
|
dfe837dfd0 | ||
|
|
a53c07d71f | ||
|
|
e3071f5d50 | ||
|
|
601849be11 | ||
|
|
1f75fb19af | ||
|
|
a6d4a18592 | ||
|
|
e756007551 | ||
|
|
9719e36723 | ||
|
|
912ecd04f7 | ||
|
|
dd8c7fd7bd | ||
|
|
e9b71522ed | ||
|
|
73a9b0bf51 | ||
|
|
882cb67f7a | ||
|
|
2738f799f2 | ||
|
|
82d24a8feb | ||
|
|
3d15f9fc8e | ||
|
|
990078efce | ||
|
|
53f3ca33b5 | ||
|
|
eaecf563ff | ||
|
|
321cac2e80 | ||
|
|
0ea4b822ae | ||
|
|
1da58e5231 | ||
|
|
cad0ea3c9c | ||
|
|
03638a7f62 | ||
|
|
000df7fe45 | ||
|
|
39c1122651 | ||
|
|
44f16fedee |
@@ -22,14 +22,14 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**106 open, 33 closed**, across 34 themes.
|
||||
**110 open, 33 closed**, across 35 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
| [Affordances](todo/affordances/) | 2 | 0 |
|
||||
| [Agents coordinating agents](todo/agent-coordination/) | 5 | 0 |
|
||||
| [Speaking into a session from the backend](todo/agent-delivery/) | 1 | 1 |
|
||||
| [Backburner](todo/backburner/) | 3 | 0 |
|
||||
| [Backburner](todo/backburner/) | 4 | 0 |
|
||||
| [Background jobs](todo/background-jobs/) | 2 | 1 |
|
||||
| [Calendar and contacts](todo/calendar/) | 1 | 0 |
|
||||
| [Chat interface](todo/chat/) | 1 | 0 |
|
||||
@@ -40,7 +40,8 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
| [Fleet — one command across every server](todo/fleet/) | 6 | 2 |
|
||||
| [Headscale](todo/headscale/) | 0 | 5 |
|
||||
| [Infra (alpha)](todo/infra/) | 7 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 6 | 4 |
|
||||
| [Integrations — external tools worth wrapping as a plugin](todo/integrations/) | 1 | 0 |
|
||||
| [Known bugs](todo/known-bugs/) | 8 | 4 |
|
||||
| [Memory — one store instead of per-harness files](todo/memory/) | 1 | 0 |
|
||||
| [Mobile apps](todo/mobile-apps/) | 5 | 0 |
|
||||
| [Multi-user](todo/multi-user/) | 17 | 5 |
|
||||
@@ -70,13 +71,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `2026-09-22` **caldav retries `brew install radicale` on every boot, and there is no such formula** — [known-bugs/radicale-cannot-be-installed-on-macos](todo/known-bugs/radicale-cannot-be-installed-on-macos.md)
|
||||
- `2026-09-04` **M3E Canvas as a plugin — sketch a UI, hand the prompt to the agent** — [backburner/m3e-canvas-as-a-plugin](todo/backburner/m3e-canvas-as-a-plugin.md)
|
||||
- `2026-08-29` **Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it** — [known-bugs/officer-blocks-its-event-loop-parsing-transcripts](todo/known-bugs/officer-blocks-its-event-loop-parsing-transcripts.md)
|
||||
- `2026-08-27` **Turn an ordinary chat into a commander or a bot, carrying its context along** — [agent-coordination/turn-a-chat-into-a-commander-or-a-bot](todo/agent-coordination/turn-a-chat-into-a-commander-or-a-bot.md)
|
||||
- `2026-08-27` **The calendar API cannot express repeating events, reminders, invitees or a cancelled occurrence** — [calendar/the-json-api-cannot-express-recurrence-alarms](todo/calendar/the-json-api-cannot-express-recurrence-alarms.md)
|
||||
- `2026-08-27` **AGENTS.md sweep — what is left after the static docs landed** — [docs-conventions/agents-md-remaining](todo/docs-conventions/agents-md-remaining.md)
|
||||
- `2026-08-27` **OpenSEO — a strong plugin candidate, checked rather than assumed** — [integrations/openseo-a-plugin-candidate](todo/integrations/openseo-a-plugin-candidate.md)
|
||||
- `2026-08-27` **The calendar/contacts skill has never been exercised by an actual agent** — [known-bugs/calendar-skill-never-used-by-an-agent](todo/known-bugs/calendar-skill-never-used-by-an-agent.md)
|
||||
- `2026-08-27` **Setting up a phone for calendar and contacts should be one link inside Officer, not a manual account form** — [mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app](todo/mobile-apps/setting-up-a-phone-should-be-a-link-in-the-app.md)
|
||||
- `2026-08-27` **Marketplace: browse by tag — filter list, card grid, detail with a back button** — [plugin-ecosystem/marketplace-browse-by-tag](todo/plugin-ecosystem/marketplace-browse-by-tag.md)
|
||||
- `2026-08-25` **An affordance: a scoped agent warns when a request lands outside its scope** — [affordances/an-affordance-a-scoped-agent-warns-on-the](todo/affordances/an-affordance-a-scoped-agent-warns-on-the.md)
|
||||
- `2026-08-25` **Commit signing is inconsistent across harnesses — Claude names itself, opencode names nothing** — [agent-coordination/commit-signing-is-inconsistent-across-harnesses](todo/agent-coordination/commit-signing-is-inconsistent-across-harnesses.md)
|
||||
- `2026-08-25` **A commander's watcher does not survive a reboot, and nothing says so** — [agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot](todo/agent-coordination/a-commander-s-watcher-does-not-survive-a-reboot.md)
|
||||
- `2026-08-25` **Resurrect Projects/Apps** — [backburner/resurrect-projects-apps](todo/backburner/resurrect-projects-apps.md)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"name": "officer",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
"@anthropic-ai/sdk": "^0.71.0",
|
||||
"@bitcoinerlab/secp256k1": "1.2.0",
|
||||
"@getalby/sdk": "^8.0.3",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -324,6 +325,8 @@
|
||||
|
||||
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.59", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-xPOUZZimZI5ChaO791olWGXqaRvCwOfj9/1micu42EL9czdcwiDm0WK1OGsqb2mZ7LSCoYWBB0ZHVKOxehemDA=="],
|
||||
|
||||
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
|
||||
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
@@ -1616,6 +1619,8 @@
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
@@ -2290,6 +2295,8 @@
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# 2026.08.29.03 — deploy report
|
||||
|
||||
**7 of 7 target hosts succeeded. `officer-dev` skipped, as required.**
|
||||
|
||||
Tag `deploy/2026.08.29.03` → `03fbb118`, matching `package.json`. Plan:
|
||||
[`deploy/2026.08.29.03.md`](2026.08.29.03.md). Every host ended at `03fbb118`.
|
||||
|
||||
Rolled out one host at a time, first release under a new watcher.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | `plugins:update` | `officer` restart_time | health | `file-browser/ls` |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `78bf63c2` | `03fbb118` | 15/15 | 4 → 5 | 401 | 200, 76 entries |
|
||||
| `fofos` | `21665ed0` | `03fbb118` | 5/5 | 45 → 46 | 401 | 200, 38 entries |
|
||||
| `macbookpro` | `78bf63c2` | `03fbb118` | none installed | 4 → 5 | 401 (:9010) | 200, 62 entries |
|
||||
| `macbruno` | `78bf63c2` | `03fbb118` | 1/1 | 7 → 8 | 401 | 200, 41 entries |
|
||||
| `production-pertento-bridge` | `78bf63c2` | `03fbb118` | 1/1 | 5 → 6 | 401 (:9010) | 200, 53 entries |
|
||||
| `edge-pertento-bridge` | `78bf63c2` | `03fbb118` | 2/2 | 6 → 7 | 401 | 200, 29 entries |
|
||||
| `deadpool-rezio-bridge` | `78bf63c2` | `03fbb118` | 2/2 | 23 → 24 | 401 | 200, 49 entries |
|
||||
|
||||
Every host: tracked tree clean, `git merge --ff-only deploy/2026.08.29.03` fast-forwarded, `officer`
|
||||
came back `online` with a moved `restart_time`, and no process on any host was left off-line. Ports
|
||||
were read from each checkout's own `.env` rather than assumed. `401` on `/api/health` is healthy per
|
||||
the plan.
|
||||
|
||||
## What was NOT done
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. The rollout ran there, inside
|
||||
`officer-claude-code`; restarting would have ended it mid-deploy. It is already at `03fbb118`, so no
|
||||
code is missing — only its `officer` process still runs the previous build. The owner restarts it
|
||||
deliberately.
|
||||
- **No `bun db:push`, no `bun install`, no SQL.** The plan states there is no schema and no dependency
|
||||
change, and says improvising either is itself the error. Nothing was queried because nothing was
|
||||
changed. **This release has no schema verification, by design.**
|
||||
- **Orphaned `todos` rows in `role_permissions` left alone**, as the plan instructs. Absence denies.
|
||||
|
||||
## Worth knowing
|
||||
|
||||
- **A commit landed on `master` mid-rollout.** `origin/master` reached `44f16fed` while hosts 4–7 were
|
||||
fetching. No host took it: every merge targeted the tag, not the branch, so the fleet is uniformly at
|
||||
`03fbb118`. `44f16fed` is undeployed and rides the next release. This is the 2026-08-23 failure not
|
||||
happening.
|
||||
- **`plugins:update` moved more than `officer`.** It restarts sidecars itself — around twelve on
|
||||
`alpha` — and added and started one each on `production-pertento-bridge` (gitea),
|
||||
`edge-pertento-bridge` (offscale) and `deadpool-rezio-bridge` (email).
|
||||
- **`fofos` has an untracked `scripts/chk.ts`.** Left in place. Untracked files are a note, not a skip.
|
||||
- **`edge-pertento-bridge` needed no special handling.** `docs/servers.md` warns it may be far behind
|
||||
after its outage; it was already at `deploy/2026.08.29.02`. That warning is now stale.
|
||||
- **`/todos` now 404s on all seven hosts.** Intended — the board is the `kanban` plugin, installed
|
||||
nowhere by default.
|
||||
@@ -0,0 +1,90 @@
|
||||
# 2026.08.29.04 — the file preview becomes a carousel, and zoom starts working again
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.03..deploy/2026.08.29.04 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --name-only deploy/2026.08.29.03..deploy/2026.08.29.04 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
|
||||
Sixteen commits; two of them are notes and one is the previous release's report.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **The file preview is a carousel.** It lists the folder it was opened from, shows thumbnails along the
|
||||
bottom, and steps with the arrow keys or the carets. Escape closes it.
|
||||
- **Zoom works on plugin screens, `/bots` and `/plugins` again.** `useAuthoredLayout` grafts back only
|
||||
what the user owns, and it knew about sizes and not about zoom — so every click was collected by
|
||||
nothing, compared equal, and dropped. The control rendered, took the click, and did nothing.
|
||||
- **An image no longer sticks.** Stepping to a new picture showed the previous one until the new one
|
||||
decoded, and the first fix for that lost a race against cached images and left the spinner up forever.
|
||||
Both are gone: loaded state is derived from which `src` actually loaded.
|
||||
- **A platform event bus**, and thumbnails warmed the moment files arrive. `files:uploaded`,
|
||||
`files:placed` and `archive:extracted` are emitted; a plugin may subscribe through its manifest. No
|
||||
plugin subscribes yet, so the dispatch path is unexercised in production — worth knowing if something
|
||||
odd appears near an upload.
|
||||
- **An embedded file browser can put its folder in a query parameter**, keep its own root crumb, and
|
||||
Back and Forward move between folders.
|
||||
- Shift+Enter renders as a line break in chat. Grid tiles have their gap back and the picture fills the
|
||||
tile. The download card's title stops escaping it.
|
||||
|
||||
## Nothing here changes behaviour anyone has to be warned about
|
||||
|
||||
No route is removed, no permission changes, nothing 404s that did not before. If a host comes back and
|
||||
something is missing, that is a fault rather than an intention — report it.
|
||||
|
||||
## 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.29.04`, `bun run plugins:update`), plus:
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only — `plugins:update` has already restarted the plugin sidecars.
|
||||
|
||||
### 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.
|
||||
|
||||
### And check the file browser still lists
|
||||
|
||||
This release touched the file browser and the viewer heavily, so one call per host:
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { sign } = await import('./src/servers/jwt');
|
||||
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
|
||||
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
|
||||
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
|
||||
|
||||
## Per host
|
||||
|
||||
Surveyed 2026-08-29 while writing this. Every host was clean (`--untracked-files=no`) and six of the
|
||||
seven sit exactly on `deploy/2026.08.29.03` (`03fbb118`).
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||
| `alpha` | On `03fbb118`, clean. Ordinary target. |
|
||||
| `fofos` | **On `2738f799`, not on the tag** — it has been hand-pulled all day while this work was written against it. That commit is an ancestor of the tag, so the fast-forward is ordinary; it simply has fewer commits to travel. It is also the only host with `av-club` and `fofos-admin` installed, so `plugins:update` moves more here than anywhere else. |
|
||||
| `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**. Its `pg_dump` is older than its server — irrelevant here, since this release takes no backup and touches no schema. |
|
||||
| `edge-pertento-bridge` | Answered the survey this time, on `03fbb118` and clean. Ordinary target. |
|
||||
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
|
||||
`restart_time`, and the `ls` check's output.
|
||||
|
||||
Nothing in this release needs a database query, a backup or an install. If you find yourself running
|
||||
one, stop — the plan is wrong, and that is worth reporting rather than working around.
|
||||
@@ -0,0 +1,76 @@
|
||||
# 2026.08.29.04 — deploy report
|
||||
|
||||
**7 of 7 targets succeeded. `officer-dev` skipped by design — it is the host the rollout ran on.**
|
||||
|
||||
Every host went `03fbb118` → `e9b71522` except `fofos`, which started further back at `2738f799` and
|
||||
travelled further. All eight rows below end on `e9b71522`, the commit `deploy/2026.08.29.04` points at.
|
||||
|
||||
Nothing failed. Nothing was left half-applied. No host was unreachable — `macbookpro` was awake.
|
||||
|
||||
## What ran, per host
|
||||
|
||||
The same four steps everywhere: tracked-only dirty check, `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.08.29.04`, `bun run plugins:update`, then `pm2 restart officer` and
|
||||
nothing else. The plan asked for no `bun install` and no `bun db:push`, and neither was run.
|
||||
|
||||
| Host | HEAD before | HEAD after | plugins | `officer` restarts | health | `ls` |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | **SKIPPED — the rollout ran here** | — | — | — |
|
||||
| `alpha` | `03fbb118` | `e9b71522` | 15/15 | 5 → 6 | 401 | 200, 77 entries |
|
||||
| `fofos` | `2738f799` | `e9b71522` | 5/5 | 93 → 94 | 401 | 200, 39 entries |
|
||||
| `macbookpro` | `03fbb118` | `e9b71522` | none installed | 5 → 6 | 401 (9010) | 200, 62 entries |
|
||||
| `macbruno` | `03fbb118` | `e9b71522` | 1/1 | 8 → 9 | 401 | 200, 41 entries |
|
||||
| `production-pertento-bridge` | `03fbb118` | `e9b71522` | 1/1 | 6 → 7 | 401 (9010) | 200, 53 entries |
|
||||
| `edge-pertento-bridge` | `03fbb118` | `e9b71522` | 2/2 | 7 → 8 | 401 | 200, 29 entries |
|
||||
| `deadpool-rezio-bridge` | `03fbb118` | `e9b71522` | 2/2 | 24 → 25 | 401 | 200, 49 entries |
|
||||
|
||||
`plugins:update` exited 0 on every host that has plugins. `401` on `/api/health` is healthy — the plan
|
||||
says so and every host gave the same answer.
|
||||
|
||||
## The file browser check
|
||||
|
||||
This release rewrote the file browser and the viewer, so the plan asked for one signed
|
||||
`/api/file-browser/ls` call per host. **Seven hosts, seven `200`s**, each with a plausible entry count
|
||||
for that machine. The counts differ because the homes differ; none was zero and none was a 500.
|
||||
|
||||
Signed as user id 1 with that host's own owner, which is not the same address everywhere —
|
||||
`conceiba@gmail.com` on `macbruno` and `brunorezio@gmail.com` on `deadpool-rezio-bridge`. Read from each
|
||||
host's own `users` table rather than assumed.
|
||||
|
||||
## Schema
|
||||
|
||||
**No schema verification was performed, because this release changes no schema.** The plan states that
|
||||
and shows the two `git diff --name-only` commands behind the claim. No `bun db:push` step exists in it
|
||||
and none was improvised.
|
||||
|
||||
The plugin sidecars did apply their own schemas during `plugins:update` — `music` on `alpha`,
|
||||
`fofos-admin` and `proxy-manager` on `fofos`, `proxy-manager` on `macbruno`, `offscale` on
|
||||
`edge-pertento-bridge`, `email` on `deadpool-rezio-bridge`. Each printed its own
|
||||
`schema: applied … (N table(s) verified)`. That belongs to the plugin updater, not to this release.
|
||||
|
||||
## 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`. Every one came back `online`.
|
||||
|
||||
`ecosystem.config.cjs` was never restarted anywhere. No vault was touched.
|
||||
|
||||
## What I did not do
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, nothing restarted. It already sits on this code, and
|
||||
restarting it would have ended the session writing this report. The owner restarts it deliberately,
|
||||
last, once happy with the rest.
|
||||
- **`fofos` has an untracked `scripts/chk.ts`.** Untracked is not dirty; the fast-forward could not touch
|
||||
it and it was left exactly where it was. A note, not a skip.
|
||||
- **`production-pertento-bridge` prints an nvm warning** on every interactive shell — its `~/.npmrc` has a
|
||||
`globalconfig`/`prefix` setting nvm objects to. It is noise, it broke nothing, and it is not this
|
||||
release's doing. Worth clearing at some point so it stops appearing in deploy output.
|
||||
- **No `--force` anywhere**, no dependency install, no database write.
|
||||
|
||||
## One thing worth knowing
|
||||
|
||||
The plan flags a **platform event bus** — `files:uploaded`, `files:placed`, `archive:extracted` — that no
|
||||
plugin subscribes to yet. The dispatch path therefore ships unexercised. If something odd shows up near an
|
||||
upload on any of these hosts, that is the first place to look.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.30.01 — the file browser's clipboard moves to the server, and plugins reach further in
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.04..deploy/2026.08.30.01 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --name-only deploy/2026.08.29.04..deploy/2026.08.30.01 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exists in this plan, and neither should be improvised.**
|
||||
Eleven commits; one of them is the previous release's report.
|
||||
|
||||
The clipboard is per-user state and it needs no migration — it is a key in `user_state`, a JSONB column
|
||||
that already exists, written by the same `PATCH /user/state` the dock and the view mode already use.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **The file browser's clipboard lives on the server.** It was `useState`, so it existed in one tab and
|
||||
died with it: copying in one window and pasting in another did nothing, and a reload lost a pending cut
|
||||
with no sign that it had. It is now a key in `user_state`, per user rather than per browser — cutting on
|
||||
a desktop and pasting on a phone is one gesture across two screens.
|
||||
- **Files can be dragged between windows.** A custom MIME type on the DataTransfer survives between tabs
|
||||
and windows of the same browser, so a row can be dragged from one Officer window into a folder in
|
||||
another. It cannot cross different browsers or exchange with Finder; that is a property of the platform,
|
||||
not a gap to report.
|
||||
- **A window re-reads its folder and the clipboard when it comes forward**, so the window a file was cut
|
||||
from stops listing it. And **re-reading no longer blanks the list** — the spinner is now for a folder
|
||||
that cannot yet be shown, so a refresh after a paste or a delete leaves the rows and the scroll position
|
||||
where they were.
|
||||
- **A plugin can put an action in the file browser's context menu**, and gets an authenticated client to
|
||||
use in it. Three commits, because the first two shipped the registration without forwarding it into the
|
||||
menu, and then without a way to call the API.
|
||||
- **The file browser can be a file picker** — `onPickFile` turns opening a file into handing it back,
|
||||
rather than a second component that reimplements a listing, a breadcrumb and a sort.
|
||||
- **A plugin can hand the agent its own MCP server**, declared in its manifest.
|
||||
- **A plugin may not bring its own React.** `npmDependencies` strips and then deletes `react` and
|
||||
`react-dom` after install: a plugin's peer copy landing in `plugins/node_modules` gives the app two
|
||||
Reacts and every hook throws.
|
||||
|
||||
## Nothing here changes behaviour anyone has to be warned about
|
||||
|
||||
No route is removed, no permission changes, nothing 404s that did not before. The clipboard is additive:
|
||||
a browser that has never set one reads `null` and behaves exactly as before.
|
||||
|
||||
## 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.01`, `bun run plugins:update`), plus:
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only — `plugins:update` has already restarted the plugin sidecars.
|
||||
|
||||
### 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.
|
||||
|
||||
### And check the file browser still lists
|
||||
|
||||
This release touched the file browser heavily — the listing loader, the clipboard and the row markup —
|
||||
so one call per host:
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { sign } = await import('./src/servers/jwt');
|
||||
const t = await sign({ id: 1, email: '<the owner on this host>' }, '2m');
|
||||
const r = await fetch('http://127.0.0.1:<PORT>/api/file-browser/ls?root=home&path=/', { headers: { Authorization: 'Bearer ' + t } });
|
||||
const b = await r.json(); console.log(r.status, (b.entries ?? []).length + ' entries');
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
A `200` with a plausible count. **A 500 here is a failed host** — report it and move on.
|
||||
|
||||
## Per host
|
||||
|
||||
Surveyed 2026-08-30 while writing this. Every host answered, and every one is clean
|
||||
(`--untracked-files=no`).
|
||||
|
||||
| Host | HEAD at survey | Note |
|
||||
| --- | --- | --- |
|
||||
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||
| `alpha` | `e9b71522` | Exactly on the previous tag. Ordinary target. |
|
||||
| `fofos` | `601849be` | **Ahead of the previous tag, behind this one.** It has been hand-pulled all day while the fofos plugin work was written against it. That commit is an ancestor of this tag, so the fast-forward is ordinary — it simply travels fewer commits. It is also the only host with `av-club` and `fofos-admin` installed, so `plugins:update` moves more here than anywhere else. |
|
||||
| `macbookpro` | `e9b71522` | macOS, port **9010**, a laptop. Answered the survey; if it has slept since, unreachable is a SKIP with a note, not a failure. |
|
||||
| `macbruno` | `e9b71522` | macOS, login **`brunoalcadapadezmonteirodaconceicao`**. |
|
||||
| `production-pertento-bridge` | `e9b71522` | Port **9010**. |
|
||||
| `edge-pertento-bridge` | `e9b71522` | Ordinary target. |
|
||||
| `deadpool-rezio-bridge` | `e9b71522` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
|
||||
`restart_time`, and the `ls` check's output.
|
||||
|
||||
Nothing in this release needs a database query, a backup or an install. If you find yourself running one,
|
||||
stop — the plan is wrong, and that is worth reporting rather than working around.
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,129 @@
|
||||
# 2026.09.09.01 — ten days of platform work, and one new table
|
||||
|
||||
**Twenty-seven commits**, `deploy/2026.08.30.03..fafc1adb`. The fleet is ten days behind; this is a
|
||||
catch-up release rather than a fix for anything found in the last one.
|
||||
|
||||
The larger threads in it, so a host that misbehaves has somewhere to point:
|
||||
|
||||
- **Plugin ports** — the platform can allocate a plugin a stable port and remember it (`518d329c`,
|
||||
`d9110c37`). This is the schema change; see below.
|
||||
- **Publishing is a tag** — the marketplace reads releases, not working trees (`78e89ebc`, `19bbbab9`).
|
||||
- **Plugin routing** — a plugin owns the routing under its mount and can declare its own routes
|
||||
(`89820972`, `1fe767ea`), with `:section` given back to the fifteen plugins the splat took it from
|
||||
(`eaa92738`).
|
||||
- **The code editor's tree and file browser** are exported for plugins to reuse (`fb530456`, `5fa3f3ec`,
|
||||
`da07048f`).
|
||||
- **`anthropicViaProxy`** (`fafc1adb`) — the boundary through which platform-side code, including a
|
||||
plugin's server routes, reaches Claude on the owner's own subscription.
|
||||
|
||||
## The schema change — one new table, one nullable column
|
||||
|
||||
Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.30.03..master | grep -iE 'schema|databases/'
|
||||
```
|
||||
|
||||
Two files, and they are **not** equally in scope:
|
||||
|
||||
1. **`officer_db` — `plugin_ports`, a brand-new table.** No foreign key onto it and no existing column
|
||||
pointing at it, so this is not the create-then-constrain shape that `db:push` cannot do. A plain
|
||||
`bun db:push` is enough, and it is a create on every host.
|
||||
|
||||
2. **`marketplace_db` — `plugins.release_tag`, a nullable text column.** **Out of scope for this
|
||||
rollout.** `bun db:push` covers `officer_db` only, and the marketplace runs on exactly one machine:
|
||||
|
||||
```
|
||||
officer-marketplace → officer-dev only (verified 2026-09-09, pm2 jlist on all six)
|
||||
```
|
||||
|
||||
`officer-dev` is the host the rollout skips. So no target needs it, and nothing should improvise a
|
||||
second push to reach it. It is named here so that when `officer-dev` is restarted deliberately, the
|
||||
marketplace database is known to be the one outstanding piece.
|
||||
|
||||
## Dependency change — `bun install` is required
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.30.03..master -- package.json bun.lock # both changed
|
||||
```
|
||||
|
||||
`@anthropic-ai/sdk@^0.71.0` was added (plus its `json-schema-to-ts` dependency). The lockfile is
|
||||
committed and matches, so the frozen-lockfile default resolves from it — **plain `bun install`, never
|
||||
`--no-frozen-lockfile`.** A host that cannot resolve from the lockfile is a finding, not something to
|
||||
force past.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the rollout (dirty check — TRACKED only,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.09.09.01`, `bun run plugins:update`), plus,
|
||||
in this order:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun db:push < /dev/null
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`bun install` **before** `plugins:update`, because the plugin installer runs against the platform's own
|
||||
resolved modules. `db:push` after both, because `gen-plugin-schemas` runs inside it and reads the
|
||||
plugins as they now are.
|
||||
|
||||
`< /dev/null` on the push is not decoration: stdin closed means a push that wants to ask a question
|
||||
fails instead of hanging forever in an unattended run. This change should ask nothing — a create of a
|
||||
table nothing else references — so **a prompt here is a finding.**
|
||||
|
||||
`pm2 restart officer` only. `plugins:update` has already restarted the plugin sidecars itself.
|
||||
|
||||
### Verify it came back — three checks, per host, by query
|
||||
|
||||
Exit 0 from `db:push` is not evidence; `drizzle-kit push` can fail and still exit 0.
|
||||
|
||||
1. **The table exists**, asked of that host's own database:
|
||||
|
||||
```bash
|
||||
psql "$DB_URL" -tAc "select to_regclass('public.plugin_ports')" # → plugin_ports
|
||||
```
|
||||
|
||||
Read the URL from that checkout's `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`, not
|
||||
`POSTGRES_URL`.
|
||||
|
||||
2. **`pm2 jlist`** — `officer` is `online` and its `restart_time` moved.
|
||||
|
||||
3. **HTTP** — `/api/health` on the host's own port. `200` or `401` are both healthy. The port is in
|
||||
`.env` and is **9010 on `macbookpro`**, 9000 elsewhere.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Login | State before | Note |
|
||||
| ----------------------- | -------------- | ------------------------------ | --------------------------------------------------------- |
|
||||
| `alpha` | `pastilhas` | `19bbbab9`, 8 commits past the tag | Partially pulled at some point; the fast-forward is shorter than the others'. |
|
||||
| `fofos` | `pastilhas` | `fafc1adb` — already at master | Already carries every commit; the merge is a no-op that only moves the tag onto it. Still runs install/push/restart. One untracked file — a note, not a skip. |
|
||||
| `macbookpro` | `pastilhas` | `c97da029` (`2026.08.30.03`) | Laptop. Asleep is a SKIP with a note. `export PATH=$PATH:/opt/homebrew/bin` for `bun`, and run `pm2` as a separate command. Port **9010**. |
|
||||
| `deadpool-rezio-bridge` | **`br`** | `c97da029` (`2026.08.30.03`) | Not `pastilhas`. |
|
||||
|
||||
### Skipped, deliberately — not failures
|
||||
|
||||
- **`officer-dev`** — the host the rollout runs on. Skipping it entirely is the rule; restarting
|
||||
`officer` there ends the rollout mid-report. It is at `fafc1adb` and therefore **not behind** in code;
|
||||
what it does still want is the marketplace column above, whenever the owner restarts it.
|
||||
- **`macbruno`** — unreachable at cut time (`connect to host macbruno port 22: Connection timed out`,
|
||||
verified 2026-09-09). A Mac, so most likely asleep.
|
||||
- **Both Pertento hosts** — on the deliberately-skipped list in `docs/servers.md` since 2026-09-08.
|
||||
Owner's call, no fault on either box.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Four hosts attempted: `alpha`, `fofos`, `macbookpro`, `deadpool-rezio-bridge`. Each at
|
||||
`deploy/2026.09.09.01`, `plugin_ports` present by query, `officer` back online, health answering on the
|
||||
right port.
|
||||
|
||||
A host that fails does not stop the rollout. It gets its own line saying which step failed and what
|
||||
never ran.
|
||||
|
||||
## Known and carried, not introduced here
|
||||
|
||||
`bunx tsgo` reports one pre-existing type error in `src/servers/sidecar/claude/claude-manager.ts:667`
|
||||
(`SdkUserMessage` vs `SDKUserMessage`, from the agent SDK's own types). It is present on `master`
|
||||
before this release's commits, it is type-only, and Bun strips types at run time — so it does not
|
||||
affect the rollout. Named here so it is not discovered mid-deploy and mistaken for something this
|
||||
release did.
|
||||
@@ -0,0 +1,71 @@
|
||||
# 2026.09.09.01 — report
|
||||
|
||||
**4 of 4 target hosts succeeded.** `officer-dev` skipped entirely, as the host the rollout ran on.
|
||||
`macbruno` and both Pertento hosts skipped per the plan and `docs/servers.md`.
|
||||
|
||||
Every target ended at `be3139f9` — the commit `deploy/2026.09.09.01` points at. `plugin_ports` was
|
||||
verified **by query on each host's own database**, not inferred from `db:push` exit codes.
|
||||
|
||||
**Two hosts were already at the tag before the rollout touched them, and the cause was a person, not a
|
||||
process.** `alpha` was at `be3139f9` rather than the `19bbbab9` the plan recorded, and `fofos` was at
|
||||
`be3139f9`. The deploy agent inferred `auto-pull.sh` on its minute crontab; **that inference is wrong
|
||||
and is corrected here** so nobody spends time hunting a mechanism that was not involved.
|
||||
|
||||
What actually happened: the session that cut this release also rolled `alpha` by hand before being
|
||||
stopped — the release is the releaser's job and the rollout is this commander's, and that line was
|
||||
crossed. `fofos` was at `master` for an unrelated reason: it had been deployed to repeatedly through
|
||||
the day during work on the `fofos-website` plugin.
|
||||
|
||||
So their merges were no-ops. Only `macbookpro` and `deadpool-rezio-bridge` were genuine fast-forwards,
|
||||
and they are also the only two where `bun install` had work to do.
|
||||
|
||||
The `auto-pull` reading is worth keeping visible rather than deleting, because it is exactly the shape
|
||||
of a plausible wrong answer: a real cron, on the right machine, with the right effect, that had nothing
|
||||
to do with this. A host being unexpectedly current is evidence of *something*, and "the obvious
|
||||
candidate" is not the same as the cause.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | `bun install` | `plugins:update` | `plugin_ports` by query | `officer` restart_time | health |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | — | — | — | **SKIPPED** — the host this ran on |
|
||||
| `alpha` | `be3139f9` → `be3139f9` | no changes | **16/17, exit 1** | ✓ present, 5 cols | 10 → 11, online | `401` (9000) |
|
||||
| `fofos` | `be3139f9` → `be3139f9` | no changes | **4/5, exit 1** | ✓ present, 5 cols | 380 → 381, online | `401` (9000) |
|
||||
| `macbookpro` | `c97da029` → `be3139f9` | **+ @anthropic-ai/sdk@0.71.2** | no plugins installed | ✓ present, 5 cols | 0 → 1, online | `401` (**9010**) |
|
||||
| `deadpool-rezio-bridge` | `c97da029` → `be3139f9` | **+ @anthropic-ai/sdk@0.71.2** | 2/2, exit 0 | ✓ present, 5 cols | 0 → 1, online | `401` (9000) |
|
||||
|
||||
`401` on `/api/health` is healthy — the endpoint is behind auth. Ports were read from each `.env`.
|
||||
|
||||
The schema check run per host was `select to_regclass('public.plugin_ports')`, answered `plugin_ports`
|
||||
on all four, with columns `id`, `app_name`, `name`, `port`, `allocated_at timestamptz`. Databases:
|
||||
`officer_dev` on `alpha` and `macbookpro`, `officer` on `fofos` and `deadpool-rezio-bridge`.
|
||||
No `db:push` prompted for anything — `< /dev/null` was used on every host, as the plan required.
|
||||
|
||||
## What the owner should act on
|
||||
|
||||
- **`plugins:update` failed on two hosts, both for uncommitted work in a plugin checkout.** Nothing was
|
||||
discarded. Each is one `git commit` or `git checkout --` away from clean:
|
||||
- `alpha` — `plugins/av-club` at `62972c1`, 3 modified: `docs/SECURITY.md`, `sidecar/config.ts`,
|
||||
`sidecar/paths.ts`. That plugin did **not** update on `alpha` and is still on its old checkout.
|
||||
- `fofos` — `plugins/fofos-website` at `7001eb0`, 1 modified: `frontend/Layout/Footer/Footer.tsx`.
|
||||
Same consequence: that plugin did not update.
|
||||
- **`alpha` could not fetch.** `git fetch origin --tags` failed with
|
||||
`Connection closed by 77.42.39.155 port 22` — gitea's public SSH, rate-limited at the proxy. Its
|
||||
`origin` is `git@gitea.officer.dev`. It made no difference this time because the tag was already
|
||||
local, but a host that genuinely needed the tag would have failed there. **The remote on that host is
|
||||
the thing that is wrong**; pointing it at the tailnet address removes the whole class. The same
|
||||
recommendation was made in the `2026.08.30.03` report about `macbookpro`, which fetched cleanly today.
|
||||
- **`macbruno` was not attempted.** The plan recorded it unreachable at cut time (`Connection timed
|
||||
out`, 2026-09-09) and named it skipped, so no retry budget was spent on it. It is at `c97da029` as far
|
||||
as anyone knows and will be a real fast-forward whenever it is next reached.
|
||||
- **`officer-dev` still wants the marketplace column.** `plugins.release_tag` in `marketplace_db` is
|
||||
out of scope for this rollout by the plan's own words — `bun db:push` covers `officer_db` only, and
|
||||
`officer-marketplace` runs on this host alone, which is the host the rollout skips. Whoever restarts
|
||||
`officer-dev` deliberately should apply it then. Nothing improvised a second push to reach it.
|
||||
|
||||
## Not done, deliberately
|
||||
|
||||
- `officer-dev` — not pulled, not restarted, nothing touched. It is already at `be3139f9` in code.
|
||||
- `macbruno`, `production-pertento-bridge`, `edge-pertento-bridge` — skipped, no fault on any of them.
|
||||
- The `bunx tsgo` error at `src/servers/sidecar/claude/claude-manager.ts:667` was neither checked nor
|
||||
fixed. The plan names it as pre-existing and type-only; nothing in this rollout would have surfaced it.
|
||||
@@ -0,0 +1,70 @@
|
||||
# 2026.09.14.01 — command-hq self-reported arm/fire/disarm
|
||||
|
||||
**One commit**, `deploy/2026.09.09.01..36db9a2a`. Backend-only, no schema change, no new dependency.
|
||||
|
||||
## What it is
|
||||
|
||||
Three new routes, `POST /command-hq/commanders/dir/:dirName/{arm,fire,disarm}` — a second, self-reported
|
||||
door onto the existing `armCommandHqWatcher`/`recordCommandHqWatcherFired`/`disarmCommandHqWatcher`
|
||||
functions, for a commander loop that outlives its own turn (detached, or a tracked background job that
|
||||
dies with the turn anyway) and so never produces the harness's own `task:started`/`task:notification`
|
||||
events. Authenticated with the commander's own long-lived `OFFICER_API_KEY`. Full account in
|
||||
`docs/command-hq.md` → "Self-reported arm".
|
||||
|
||||
No route removed, no existing route's contract changed, no frontend change. The only way this release
|
||||
does anything is a host restarting `officer` to pick up the new routes — a commander whose loop already
|
||||
calls them (this machine's four) gets nothing from the rollout on any OTHER host, because those hosts'
|
||||
own commanders, if any, don't call them yet.
|
||||
|
||||
## Verified, not assumed
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.09.09.01..HEAD | grep -iE 'schema|databases/' # → nothing
|
||||
git diff --name-only deploy/2026.09.09.01..HEAD -- package.json bun.lock # → nothing
|
||||
git diff --name-only deploy/2026.09.09.01..HEAD -- docs/servers.md # → nothing, host list unchanged
|
||||
bun run docs:check # → green
|
||||
```
|
||||
|
||||
No schema change, no dependency change, no host-list change since the last release. `package.json`
|
||||
version bumped to match this tag and nothing else in it changed.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
Standard steps only, nothing extra: dirty check (TRACKED only), `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.09.14.01`, `bun run plugins:update`, `pm2 restart officer`. No
|
||||
`bun install`, no `db:push` — neither is needed and running either is not a finding, just unnecessary.
|
||||
|
||||
### Verify it came back — two checks, per host
|
||||
|
||||
No schema table to query this time, so the usual three checks drop to two:
|
||||
|
||||
1. **`pm2 jlist`** — `officer` is `online` and its `restart_time` moved.
|
||||
2. **HTTP** — `/api/health` on the host's own port. `200` or `401` are both healthy. Port is **9010 on
|
||||
`macbookpro`**, 9000 elsewhere.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Login | Note |
|
||||
| ----------------------- | -------------- | ----------------------------------------------------------|
|
||||
| `alpha` | `pastilhas` | |
|
||||
| `fofos` | `pastilhas` | |
|
||||
| `macbookpro` | `pastilhas` | Laptop. Asleep is a SKIP with a note. Port **9010**. |
|
||||
| `deadpool-rezio-bridge` | **`br`** | Not `pastilhas`. |
|
||||
|
||||
### Skipped, deliberately — not failures
|
||||
|
||||
- **`officer-dev`** — the host the rollout runs on. Skip entirely, per the standard rule.
|
||||
- **`macbruno`** — reachability unknown at cut time; check live, unreachable is a SKIP not a failure.
|
||||
- **Both Pertento hosts** — deliberately-skipped list in `docs/servers.md` since 2026-09-08.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Four hosts attempted: `alpha`, `fofos`, `macbookpro`, `deadpool-rezio-bridge`. Each at
|
||||
`deploy/2026.09.14.01`, `officer` back online, health answering on the right port. A host that fails
|
||||
does not stop the rollout — its own line, what failed, what never ran.
|
||||
|
||||
## Known and carried, not introduced here
|
||||
|
||||
`bunx tsgo` reports one pre-existing type error in `src/servers/sidecar/claude/claude-manager.ts:667`
|
||||
(`SdkUserMessage` vs `SDKUserMessage`, from the agent SDK's own types) — present before this release,
|
||||
type-only, stripped at run time. Named here so it isn't mistaken for something this release did.
|
||||
@@ -0,0 +1,71 @@
|
||||
# 2026.09.14.01 — report
|
||||
|
||||
**4 of 4 hosts succeeded.** `officer-dev`, `macbruno` and both Pertento hosts skipped, all deliberately.
|
||||
|
||||
Backend-only release (`be3139f9..4a37cfd2`): three new self-reported `command-hq` routes. No schema
|
||||
change, so no `db:push` and no schema query — the plan's two checks (`pm2 jlist`, `/api/health`) are the
|
||||
whole verification, and both ran on every host.
|
||||
|
||||
Every host went `be3139f9` (`deploy/2026.09.09.01`) → `4a37cfd2` (`deploy/2026.09.14.01`), by
|
||||
`git merge --ff-only` onto the TAG, not `master`. Every checkout path was read from that host's own
|
||||
`officer` `pm_cwd`, never assumed.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Before | After | Dirty | plugins:update | Restarted | Back? |
|
||||
| ----------------------- | ---------- | ---------- | ----- | -------------- | --------- | --------------------------- |
|
||||
| `alpha` | `be3139f9` | `4a37cfd2` | clean | 17/17 | `officer` | online, 0→1, health `401` |
|
||||
| `fofos` | `be3139f9` | `4a37cfd2` | clean¹| 5/5 | `officer` | online, 1→2, health `401` |
|
||||
| `macbookpro` | `be3139f9` | `4a37cfd2` | clean | none installed | `officer` | online, 1→2, health `401` (:9010) |
|
||||
| `deadpool-rezio-bridge` | `be3139f9` | `4a37cfd2` | clean | 3/3 | `officer` | online, 0→1, health `401` |
|
||||
|
||||
¹ `fofos` carries two UNTRACKED files — `flip-records.ts` and `scripts/chk.ts`. Untracked is a note, not
|
||||
a skip: a fast-forward cannot touch them and nobody's work was discarded.
|
||||
|
||||
All four checkouts were clean of TRACKED changes, so no host was skipped for a dirty tree and every merge
|
||||
was a genuine fast-forward.
|
||||
|
||||
`401` is healthy here — `/api/health` is authenticated, and the plan names `200` or `401` as pass. The
|
||||
port was read from each host's own `.env` rather than assumed: `9010` on `macbookpro`, `9000` elsewhere.
|
||||
|
||||
## Steps that ran
|
||||
|
||||
Per host, in order: tracked-only dirty check → `git fetch origin --tags` → `git merge --ff-only
|
||||
deploy/2026.09.14.01` → `bun run plugins:update` → `pm2 restart officer` → `pm2 jlist` → health.
|
||||
|
||||
No `bun install` and no `bun db:push` on any host. The plan says neither is needed; running either would
|
||||
not have been a finding, but it was not needed and was not done.
|
||||
|
||||
`plugins:update` restarts sidecars itself. On `alpha` that is more processes moving than the one line
|
||||
above suggests — the table's "restarted" column is what the PLAN asked for, not the full count of what
|
||||
bounced.
|
||||
|
||||
## Skipped — none of these is a failure
|
||||
|
||||
- **`officer-dev`** — the host this rollout ran on. Skipped entirely per the standard rule: not pulled,
|
||||
nothing restarted. **It is still on `be3139f9` and the owner restarts it deliberately.**
|
||||
- **`macbruno`** — `ssh` timed out on port 22 (`Connection timed out`, 8s). The plan names it as
|
||||
check-live, unreachable-is-a-skip. Not attempted further; it cost one probe, not a retry budget.
|
||||
- **`production-pertento-bridge`, `edge-pertento-bridge`** — on the deliberately-skipped list in
|
||||
`docs/servers.md` since 2026-09-08. Owner's call. Nothing spent on either.
|
||||
|
||||
## What the owner must act on
|
||||
|
||||
- **`officer-dev` has not been deployed.** It is the one host still on the previous release. This is the
|
||||
designed outcome, not an oversight — restarting `officer` here would have killed the rollout mid-flight.
|
||||
- **`macbruno` is behind by one release** and unreachable as of this rollout. It will need the same
|
||||
four-step sequence whenever it is back.
|
||||
- Nothing else. No host failed, no process came back `errored`, no step was refused.
|
||||
|
||||
## Carried, not introduced
|
||||
|
||||
`bunx tsgo` reports one pre-existing type error in `src/servers/sidecar/claude/claude-manager.ts:667`
|
||||
(`SdkUserMessage` vs `SDKUserMessage`, from the agent SDK's own types). Named in the plan, present before
|
||||
this release, type-only. Not checked again here and not caused by this rollout.
|
||||
|
||||
## About this run
|
||||
|
||||
First release under a new watcher. The rollout procedure is proven — this is the tenth report — but this
|
||||
loop had never executed it before, so every host was narrated before it was touched. The claim was taken
|
||||
by the watcher that spawned this agent; this agent did not re-claim, and recorded `done` only after the
|
||||
fourth host came back healthy.
|
||||
+39
-3
@@ -62,11 +62,14 @@ for exact request/response shapes; the table below is the map, not the full cont
|
||||
| GET | `/commanders/:sessionId/turns` | — | `{turns: [...]}` — the raw per-turn cost ledger, oldest first. What "cost over time" plots from |
|
||||
| GET | `/commanders/:sessionId/fires` | — | `{fires: [...]}` — every trigger, oldest first. The full history behind `watcherLastFiredAt` |
|
||||
| POST | `/commanders` | `{sessionId, categoryId?}` | files an EXISTING session; idempotent; 404 if the session isn't in the commanders directory. Not the creation path — see `/commanders/new` |
|
||||
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms (arming has no route — needs a live task id) |
|
||||
| PATCH | `/commanders/:sessionId` | `{categoryId?, name?, watcherArmed?}` | `name` proxies to `renameClaudeSession`; `watcherArmed: false` disarms. This route still can't arm — see the `dir/:dirName` routes below for the one path that can from outside a running turn |
|
||||
| DELETE | `/commanders/:sessionId` | — | deletes the TRANSCRIPT (`/clear` chain and all) via `deleteClaudeSession`, then the row. Not reversible, and not the same thing `/purge` does — see "The `/chat` ↔ `/command-hq` relationship" below |
|
||||
| POST | `/purge` | — | sweeps rows whose transcript no longer resolves; returns `{removed}` |
|
||||
| POST | `/commanders/:sessionId/tags` | `{tagId}` | |
|
||||
| DELETE | `/commanders/:sessionId/tags/:tagId` | — | |
|
||||
| POST | `/commanders/dir/:dirName/arm` | `{taskId}` | self-reported arm, for a loop the harness never sees fire — see "Self-reported arm" below. `:dirName` is the commander's own numeric id as a string, not the transcript uuid |
|
||||
| POST | `/commanders/dir/:dirName/fire` | `{taskId}` | self-reported trigger; no-ops (`{ok:false}`) unless `taskId` matches the row's current `watcherTaskId` |
|
||||
| POST | `/commanders/dir/:dirName/disarm` | — | self-reported clean exit; same effect as `PATCH {watcherArmed:false}`, resolved by dirName instead |
|
||||
|
||||
**A commander's conversation is `GET /chat/sessions/:id`, unchanged.** This router deliberately does not
|
||||
duplicate it — `GET /commanders/:sessionId` returns Command HQ's own metadata: `{sessionId, categoryId,
|
||||
@@ -463,8 +466,9 @@ occur.
|
||||
`recordCommandHqTurn` in `queries.ts`; exposed read-only on `GET /commanders` and
|
||||
`GET /commanders/:sessionId` (`watcherLastFiredAt` survives as a name in the JSON response — it is just
|
||||
computed there, `MAX(fired_at)`, not read off a column), and `PATCH /commanders/:sessionId` accepts
|
||||
`{watcherArmed: false}` to disarm (arming itself has no route — it needs a live task id, which only a
|
||||
running turn has).
|
||||
`{watcherArmed: false}` to disarm. Arming still has no route THERE — it needs a live task id, which only
|
||||
a running turn has — but see "Self-reported arm" below for the route that exists precisely because a
|
||||
loop that outlives its turn doesn't have one.
|
||||
- **`armWatcher` threaded end-to-end, client to server.** `BackgroundJobDialog` computes it (`watching &&
|
||||
onFire === 'handoff'`) → `sendWithHiddenPrefix` → `QueuedPrompt.armWatcher` → `sendPrompt`'s new last
|
||||
parameter → the chat websocket message → `ClientMessage`'s `'chat'` variant → `startClaudeTurn`'s
|
||||
@@ -537,6 +541,38 @@ OWN user id, not the row it belonged to). Found by re-reading every call site th
|
||||
after the fact, not by any tool — worth remembering the next time a signature changes shape rather than
|
||||
just its types: a compiler that stays quiet is not the same as a diff that is correct.
|
||||
|
||||
### Self-reported arm — 2026-09-14
|
||||
|
||||
The mechanism above has a gap it never surfaced until four real commanders on the owner's own machine were
|
||||
checked against it: `pendingArmWatcher` is consumed on the harness's `task:started` for the live session
|
||||
that submitted the arm dialog, and stays valid only as long as that session's own tracked-background-task
|
||||
plumbing does. A loop that needs to outlive its turn — `setsid`/`nohup`/`disown`, or any tracked background
|
||||
job that the harness itself drops when the turn ends, which multiple real commanders here do on purpose,
|
||||
one of them (`docs`-adjacent `WATCHERS.md` for the OCR watcher) as an explicit standing decision favouring
|
||||
job-panel visibility over survival — never produces that event. `watcherArmed` stayed `false` and
|
||||
`command_hq_watcher_fires` stayed empty forever for every one of them, even while the loop genuinely polled
|
||||
and genuinely fired, repeatedly, for weeks. Not cosmetic: `GET /commanders`'s card already papers over the
|
||||
`watcherArmed` boolean with a live pid check (`row.watcherArmed || liveLoopPid(...) !== null`, `router.ts`),
|
||||
so the badge was always right — but nothing papers over an empty `command_hq_watcher_fires`, so
|
||||
`watcherLastFiredAt` read `null` forever on every card for a commander that had fired dozens of times.
|
||||
|
||||
Fixed by giving the loop a second, independent way to say the same thing the harness's own tracking says
|
||||
for a session-scoped commander: three routes under `/commanders/dir/:dirName/{arm,fire,disarm}`,
|
||||
authenticated with the commander's own long-lived `OFFICER_API_KEY` (the same credential its loop already
|
||||
holds and already uses for `/api/notify` and `/api/tasks/*/run` — nothing new minted), keyed on `dirName`
|
||||
because that is what a loop already knows about itself without needing its own transcript uuid. `arm` and
|
||||
`disarm` call `armCommandHqWatcher`/`disarmCommandHqWatcher` directly — the same functions `websocket.ts`
|
||||
calls, just reached from a second door. `fire` keeps the identical staleness guard `task:notification`
|
||||
already has (`watcherTaskId` must match), so a superseded instance of a loop — an old pid a crash left
|
||||
running, now replaced by a freshly-armed one — can't keep writing fires for a watch it no longer is. The
|
||||
task id itself is no longer harness-issued for this path: the loop mints its own (pid + start time is
|
||||
enough) at the top of the script and reuses it for every `fire` call until it restarts.
|
||||
|
||||
This does not change anything about the original harness-driven path — a commander whose loop genuinely is
|
||||
a tracked background job on a live session still arms exactly as before, and most of what's written above
|
||||
about `pendingArmWatcher` and the noticing in `createMessageHandler` is unchanged. It adds a second, parallel
|
||||
way to reach the same two DB functions, for the class of commander the first path structurally can't reach.
|
||||
|
||||
## Later — explicitly deferred, not forgotten
|
||||
|
||||
Raised during spec and set aside for a second version, in the owner's words: _"that's not... that's out of
|
||||
|
||||
@@ -29,17 +29,21 @@ Ranked by what actually blocks something.
|
||||
|
||||
## Staleness
|
||||
|
||||
- [ ] **Nothing refreshes the catalogue automatically.** Publishing a plugin update changes nothing until
|
||||
somebody runs `bun --env-file=.env scripts/marketplace-sync.ts`. A Gitea webhook or a cron is the
|
||||
obvious next step.
|
||||
- [x] **Nothing refreshes the catalogue automatically.** Done 2026-09-03: `scripts/marketplace-cron.sh`
|
||||
every minute. A Gitea webhook would make it a second instead, at the price of a public write route
|
||||
on the one process here with no login — not worth it for a minute.
|
||||
|
||||
- [ ] **The sync reads local clones, not the remotes.** The machine running it must have every plugin
|
||||
checked out, so the catalogue describes that machine's copies. The version that clones each repo
|
||||
into a temp directory is a small change.
|
||||
- [x] **The sync reads local clones, not the remotes.** Done 2026-09-03. Bare mirrors, read at the newest
|
||||
tag, driven by the registry rather than by a directory listing. The catalogue no longer describes
|
||||
any machine's working copies.
|
||||
|
||||
- [ ] **`rawUrl()` assumes Gitea and a `main` branch.** A GitHub-hosted plugin, or one whose default branch
|
||||
is `master`, gets a 404 for its icon. The forge belongs in the registry rather than inferred from a
|
||||
URL shape.
|
||||
- [ ] **`rawUrl()` assumes Gitea.** A GitHub-hosted plugin gets a 404 for its icon. The forge belongs in
|
||||
the registry rather than inferred from a URL shape. (The `main`-vs-`master` half of this is gone —
|
||||
URLs pin to the release tag now.)
|
||||
|
||||
- [ ] **Four plugins have never been tagged**, so the store still shows rows the old sync took from a
|
||||
working tree: av-club, kanban, openseo, fofos-admin. They are frozen until somebody tags them, and
|
||||
the sync names them on every run somebody asks for.
|
||||
|
||||
## Scale
|
||||
|
||||
|
||||
+82
-17
@@ -10,8 +10,9 @@ from. **The website installs nothing** — installing means cloning a repository
|
||||
starting a process on someone else's machine, so it happens entirely inside the owner's own Officer.
|
||||
|
||||
```
|
||||
gitea.officer.dev/plugins/* the repositories (source of truth)
|
||||
│ scripts/marketplace-sync.ts (manual, reads manifest.ts + README.md)
|
||||
gitea.officer.dev/** the repositories (source of truth)
|
||||
│ bare mirrors, read AT THE NEWEST TAG — src/servers/marketplace/sync.ts
|
||||
│ every minute, scripts/marketplace-cron.sh
|
||||
▼
|
||||
officer_marketplace (postgres) plugins + plugin_metadata
|
||||
│ src/servers/marketplace/server.tsx :9100
|
||||
@@ -20,6 +21,7 @@ marketplace.officer.dev GET /api/plugins, /api/plugins/:appName
|
||||
│ fetchCatalogue() live → cached → bundled
|
||||
▼
|
||||
an Officer install /marketplace → clone → push schema → mount
|
||||
and the update badge — see "Publishing" below
|
||||
```
|
||||
|
||||
## The parts
|
||||
@@ -78,19 +80,87 @@ disappearing from the store.
|
||||
the clone, the plugin's own manifest is what the platform reads. A metadata row that disagrees with the
|
||||
repository is a marketplace bug, never a second source of truth.
|
||||
|
||||
## Sync
|
||||
## Publishing
|
||||
|
||||
**Push a tag. That is the whole procedure.** A timer on officer-dev picks it up within a minute and every
|
||||
Officer install sees it on the next page load.
|
||||
|
||||
```bash
|
||||
bun --env-file=.env scripts/marketplace-sync.ts
|
||||
bun --env-file=.env scripts/marketplace-sync.ts # all of them, now
|
||||
bun --env-file=.env scripts/marketplace-sync.ts photos # one
|
||||
bun --env-file=.env scripts/marketplace-sync.ts --add https://gitea.officer.dev/plugins/finances.git
|
||||
bun --env-file=.env scripts/marketplace-sync.ts --force # re-publish rows this file's own logic changed
|
||||
```
|
||||
|
||||
Reads every directory under `plugins/`, imports its `manifest.ts`, derives `has` from the directory
|
||||
rather than from a declaration, reads `README.md`, and upserts. Re-running is always safe: it overwrites,
|
||||
never merges.
|
||||
### It reads releases, never a working tree
|
||||
|
||||
It rewrites SSH remotes to HTTPS. `example` and `offscale` had `git@gitea…` origins, which would have
|
||||
handed every installer a URL that only works on the machine that published it — the install fails at
|
||||
authentication, which reads as "the plugin is broken" rather than "the catalogue is wrong".
|
||||
Every registered plugin is mirrored bare into `~/.cache/officer-marketplace/mirrors` and read at its
|
||||
newest tag — manifest, README and file list all out of `git cat-file`. There is no working tree in the
|
||||
path, so publishing something uncommitted is not a mistake that can be made.
|
||||
|
||||
That is a change, and it was load-bearing. Until 2026-09-03 the sync imported `manifest.ts` off
|
||||
`platform/plugins/` — the directories THIS machine has installed — so publishing meant "get officer-dev's
|
||||
checkout to the release, then run the script", and nothing showed when that had not happened. On the day
|
||||
it was rewritten the live store was advertising four plugins from untagged working copies: av-club
|
||||
2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26, none of which had ever
|
||||
been tagged. It is also why the job could not go on a timer before: a cron over it published whatever was
|
||||
in a working tree, on a schedule.
|
||||
|
||||
### The registry decides which plugins, and where from
|
||||
|
||||
The list comes from the `plugins` table — the half this document has always called authoritative, and
|
||||
which was in fact a by-product of what happened to be checked out. Now it decides. A plugin nobody has
|
||||
installed here can still be published, and a plugin lives wherever its row says: `fofos-admin` is under
|
||||
`fofos/`, not `plugins/`, which an org listing would have missed. Registering one is `--add`.
|
||||
|
||||
### A plugin with no tag is left alone
|
||||
|
||||
It has never made a release, so the sync has nothing true to say about it: the existing row stays and the
|
||||
run names it. Deleting it would drop somebody's plugin out of the store on the day the rule changed.
|
||||
Tagging it is the fix, and the report is the reminder.
|
||||
|
||||
### `release_tag`, and why it is not `version`
|
||||
|
||||
`version` is the manifest's own claim; `release_tag` is what was pushed. They match every time somebody
|
||||
tags correctly, and when they do not, the tag is the fact — a manifest whose version nobody bumped would
|
||||
otherwise make a real release invisible to anything comparing versions. The sync warns on a mismatch.
|
||||
|
||||
Icon URLs pin to the tag too. `/raw/HEAD/…` served whatever was on the default branch, via a 303, so a
|
||||
plugin could show one icon in the store and ship another.
|
||||
|
||||
### The timer
|
||||
|
||||
`scripts/marketplace-cron.sh`, `* * * * *`, logging to `officer-dev/marketplace-sync.log`. One
|
||||
timestamped summary line per tick — always, including the ticks that do nothing, because a fresh
|
||||
timestamp is the only thing that distinguishes "nothing to publish" from "the timer died". A plugin-level
|
||||
line appears only when one actually moved.
|
||||
|
||||
A tick costs about 2.7s with nothing to do: one query, one fetch per repository, no writes. Nothing is
|
||||
written unless the release commit differs from the one already published.
|
||||
|
||||
A Gitea webhook would cut the delay from a minute to a second, at the price of a public write route and a
|
||||
shared secret. A minute was never the problem anybody had — the complaint that started this was a release
|
||||
that never appeared at all.
|
||||
|
||||
### Remotes
|
||||
|
||||
`--add` rewrites SSH to HTTPS. A `git@gitea…` origin in the catalogue hands every installer a URL that
|
||||
only works on the machine that published it, and the install fails at authentication — which reads as
|
||||
"the plugin is broken" rather than "the catalogue is wrong".
|
||||
|
||||
## The update badge reads the catalogue
|
||||
|
||||
`updateable` on `/api/plugins` compares the tag at the checkout's HEAD with the tag the catalogue says is
|
||||
newest. Difference, not ordering: parsing `2026.08.27.01` works right up until somebody tags `v2`, and
|
||||
then it fails silently.
|
||||
|
||||
Before this, each host ran a rate-limited background `git fetch` against all twenty-one repositories to
|
||||
re-derive a fact one place already knew. That is gone. `releaseState()` still holds the git rule as a
|
||||
fallback for a plugin the store cannot speak for — an unregistered checkout, or a store that could not be
|
||||
reached — and in that case its refs are whatever the last `pullPlugin` left, which is the accepted cost.
|
||||
|
||||
`fetchCatalogue()`'s cache dropped from five minutes to one when the badge started depending on it:
|
||||
otherwise the wait for a release to appear was the sync's minute plus the cache's five.
|
||||
|
||||
## The platform degrades, it does not break
|
||||
|
||||
@@ -111,13 +181,8 @@ The full list, ranked, is `docs/marketplace-todo.md`. The ones that shape the de
|
||||
- **`[blocker]` Third-party submissions cannot use this sync.** It reads a manifest by importing it, which
|
||||
is code execution. Fine for our own repositories; not a mechanism a stranger's plugin can go through.
|
||||
Submissions need either a sandbox or a published `manifest.json` alongside the TypeScript one.
|
||||
- **`[open]` Sync reads local clones, not the remotes.** The machine running it must have the plugins
|
||||
checked out. The version that clones each repo into a temp directory is a small change and was left out
|
||||
of the first pass.
|
||||
- **`[open]` `rawUrl()` assumes Gitea and a `main` branch.** A GitHub-hosted plugin or one defaulting to
|
||||
`master` gets a 404 for its icon. The forge should become part of the registry rather than inferred.
|
||||
- **`[open]` `rawUrl()` assumes Gitea.** A GitHub-hosted plugin needs the other URL form. The forge should
|
||||
become part of the registry rather than inferred from the URL.
|
||||
- **`[open]` No versions table.** `plugin_metadata` is one row per plugin — a snapshot, not a history. A
|
||||
store that can offer "install 1.2 instead of 1.3" needs a different shape.
|
||||
- **`[open]` Nothing refreshes automatically.** Publishing a plugin update does not change the catalogue
|
||||
until somebody runs the sync. A cron or a Gitea webhook is the obvious next step.
|
||||
- **`[open]` No search, no categories, no counts.** Three plugins do not need them.
|
||||
|
||||
+16
-8
@@ -21,7 +21,8 @@ ssh br@deadpool-rezio-bridge # not implied
|
||||
|
||||
## Officer platforms
|
||||
|
||||
Eight, all running. **Verified 2026-08-27** — every row below was read off the machine, not recalled.
|
||||
Six, all running. **Verified 2026-08-27**, minus the two Pertento hosts moved to "Deliberately skipped" on
|
||||
2026-09-08 — every remaining row below was read off the machine, not recalled.
|
||||
|
||||
| Host | Tailnet IP | Login | Port | Checkout | Domain |
|
||||
| ---------------------------- | ----------- | ----------------------------------------- | -------- | ----------------------------------- | --------------------------------- |
|
||||
@@ -30,8 +31,6 @@ Eight, all running. **Verified 2026-08-27** — every row below was read off the
|
||||
| `fofos` | 100.64.0.17 | `pastilhas` | 9000 | `~/officerdev/platform` | `officer.fofosfluffybastards.com` |
|
||||
| `macbookpro` | 100.64.0.9 | `pastilhas` | **9010** | `~/projects/officer-suite/platform` | `macbookpro.pastilhas.dev` |
|
||||
| `macbruno` | 100.64.0.22 | **`brunoalcadapadezmonteirodaconceicao`** | 9000 | `~/officerdev/platform` | — (none yet) |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
| `deadpool-rezio-bridge` | 100.64.0.21 | **`br`** | 9000 | `/home/br/officerdev/platform` | `officer.rezio.dev` `[pending]` |
|
||||
|
||||
**THE PORT IS NOT ALWAYS 9000.** Two hosts run on 9010 and it is in `OFFICERDEV_PORT` in each checkout's
|
||||
@@ -70,12 +69,21 @@ ssh host 'ls /opt/homebrew/bin/pm2 /usr/local/bin/pm2 ~/.nvm/versions/node/*/bin
|
||||
A host here is NOT a deploy target. A rollout names it as skipped and moves on — it is not a failure,
|
||||
and it must not be reported as one.
|
||||
|
||||
None today. `edge-pertento-bridge` was here from 2026-08-23 and came back up on 2026-08-24; it is a
|
||||
deploy target again and its row is in the table above.
|
||||
**Both Pertento hosts, since 2026-09-08** — `production-pertento-bridge` and `edge-pertento-bridge`. Owner's
|
||||
call, not a fault found on either box. Details kept here rather than dropped, so putting them back is a
|
||||
one-line move, not a re-derivation:
|
||||
|
||||
It was down long enough to matter, so expect it to be behind: when it returned it was 215 commits back at
|
||||
`2026.08.22.02`, with no `roles` table and no commanders. A host coming off this list needs its schema
|
||||
brought forward before it is treated as ordinary.
|
||||
| Host | Tailnet IP | Login | Port | Checkout | Domain |
|
||||
| ----------------------------- | ----------- | ------------ | -------- | ------------------------- | ----------------------- |
|
||||
| `production-pertento-bridge` | 100.64.0.12 | `pastilhas` | **9010** | `~/officerdev/platform` | `officer.pertento.ai` |
|
||||
| `edge-pertento-bridge` | 100.64.0.15 | `pastilhas` | 9000 | `~/officerdev/platform` | `edge.pertento.ai` |
|
||||
|
||||
`edge-pertento-bridge` was skipped once before, from 2026-08-23 to 2026-08-24 — but that was downtime, not
|
||||
a choice, and it came back as a deploy target. This is a different reason and there is no return date.
|
||||
|
||||
It was down long enough to matter last time, so expect it to be behind whenever it returns: when it came
|
||||
back on 2026-08-24 it was 215 commits back at `2026.08.22.02`, with no `roles` table and no commanders. A
|
||||
host coming off this list needs its schema brought forward before it is treated as ordinary.
|
||||
|
||||
On 2026-08-23 the deploy agent spent four attempts and up to 60s per try on this host before reporting
|
||||
it unreachable — TCP on 22 accepted, then no SSH banner. That work was correct and entirely wasted, which
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.29.03",
|
||||
"version": "2026.09.14.01",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -44,6 +44,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.2.41",
|
||||
"@anthropic-ai/sdk": "^0.71.0",
|
||||
"@bitcoinerlab/secp256k1": "1.2.0",
|
||||
"@getalby/sdk": "^8.0.3",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
|
||||
@@ -32,7 +32,7 @@ import { CATALOGUE } from '../src/servers/app-store/catalogue';
|
||||
import { PLATFORM_DIR } from '../src/servers/data-path';
|
||||
import { discoverPlugins } from '../src/servers/plugins/discover';
|
||||
import { pluginPm2Entry, type Pm2App } from '../src/servers/plugins/ecosystem';
|
||||
import { listPluginInstalls, listSidecarInstalls } from 'officerdb';
|
||||
import { listPluginInstalls, listPluginPorts, listSidecarInstalls } from 'officerdb';
|
||||
|
||||
const done = (message: string): never => {
|
||||
console.log(`[gen:ecosystem] ${message}`);
|
||||
@@ -92,7 +92,24 @@ const DISABLED = new Set(
|
||||
async function pluginEntries(): Promise<Pm2App[]> {
|
||||
const [installs, { plugins }] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
|
||||
const enabled = new Set(installs.filter((i) => i.enabled).map((i) => i.appName));
|
||||
return plugins.filter((p) => enabled.has(p.appName)).flatMap((p) => pluginPm2Entry(p) ?? []);
|
||||
/*
|
||||
Ports come from the database, not from a fresh allocation.
|
||||
|
||||
This script rebuilds the whole file from what is installed, and it must produce the SAME entries the
|
||||
installer produced — allocating here would hand a plugin a different number than the one already
|
||||
written into somebody's nginx configuration.
|
||||
*/
|
||||
const allocations = await listPluginPorts();
|
||||
const byPlugin = new Map<string, Record<string, number>>();
|
||||
for (const row of allocations) {
|
||||
const current = byPlugin.get(row.appName) ?? {};
|
||||
current[row.name] = row.port;
|
||||
byPlugin.set(row.appName, current);
|
||||
}
|
||||
|
||||
return plugins
|
||||
.filter((p) => enabled.has(p.appName))
|
||||
.flatMap((p) => pluginPm2Entry(p, byPlugin.get(p.appName)) ?? []);
|
||||
}
|
||||
|
||||
/** Installed AND enabled. Unlike a plugin, every catalogue entry has a `process` — none are filtered out. */
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env bash
|
||||
# Publish every plugin release to the marketplace catalogue. Meant for cron, on the ONE machine that
|
||||
# publishes.
|
||||
#
|
||||
# ── Why a timer at all, when a webhook would be instant ──
|
||||
#
|
||||
# The sync reads bare mirrors at their newest tag, so running it on a schedule cannot publish anything
|
||||
# that was not pushed — which is exactly what made a timer unsafe before, and is no longer true. A Gitea
|
||||
# webhook would cut the delay from a minute to a second, and it would also mean a public write route, a
|
||||
# shared secret, and a second path into the same code. A minute is not the problem anybody had: the
|
||||
# complaint that started this was a release that never appeared at all.
|
||||
#
|
||||
# ── What it costs ──
|
||||
#
|
||||
# About 2.7s per tick with nothing to do — one query, one fetch per repository, no writes. Gitea is on
|
||||
# this same machine, so none of that leaves the box.
|
||||
#
|
||||
# ── The log ──
|
||||
#
|
||||
# One timestamped summary line per tick, ALWAYS, including the ticks that do nothing. A fresh timestamp
|
||||
# is the only thing that distinguishes "nothing to publish" from "the timer died". Plugin-level lines
|
||||
# appear only for a plugin that actually moved, which is what `--quiet` buys.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
PLATFORM="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
LOG="${MARKETPLACE_SYNC_LOG:-$(dirname "$PLATFORM")/marketplace-sync.log}"
|
||||
LOCK="/tmp/officer-marketplace-sync.lock"
|
||||
|
||||
# Cron's PATH is three entries and bun is in none of them.
|
||||
export PATH="$HOME/.bun/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
|
||||
cd "$PLATFORM" || exit 1
|
||||
|
||||
# Skip rather than queue. A tick that finds the previous one still running has nothing to add — the run
|
||||
# in progress will publish whatever this one would have.
|
||||
exec 9>"$LOCK"
|
||||
flock -n 9 || exit 0
|
||||
|
||||
bun --env-file=.env scripts/marketplace-sync.ts --quiet >>"$LOG" 2>&1
|
||||
|
||||
# Bounded without logrotate, because this is one file on one machine and a config file for it would be
|
||||
# more moving parts than the thing it manages.
|
||||
if [ "$(wc -l <"$LOG")" -gt 20000 ]; then
|
||||
tail -n 5000 "$LOG" >"$LOG.tmp" && mv "$LOG.tmp" "$LOG"
|
||||
fi
|
||||
+77
-164
@@ -1,43 +1,40 @@
|
||||
#!/usr/bin/env bun
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { basename, join, resolve } from 'node:path';
|
||||
import { upsertMetadata, upsertPlugin } from '../src/databases/marketplace_db/src/plugins/queries';
|
||||
import { discoverPlugins } from '../src/servers/plugins/discover';
|
||||
import { upsertPlugin } from '../src/databases/marketplace_db/src/plugins/queries';
|
||||
import { syncAll, syncPlugin } from '../src/servers/marketplace/sync';
|
||||
|
||||
// Fill the marketplace database from the plugin repositories.
|
||||
//
|
||||
// ── What this reads, and why it is not the marketplace's own opinion ──
|
||||
//
|
||||
// Every field except "where to clone from" comes out of the plugin's `manifest.ts` and its README. The
|
||||
// marketplace stores a COPY so the website can render without cloning three repositories per page load;
|
||||
// the manifest stays the source of truth, and the platform re-reads it after cloning. A row here that
|
||||
// disagrees with the repository is a stale cache, not a second opinion — which is why re-running this is
|
||||
// always safe and never merges: it overwrites.
|
||||
//
|
||||
// ── Reading the manifest by importing it ──
|
||||
//
|
||||
// A manifest is TypeScript, not JSON, so it is imported and evaluated rather than parsed. That is code
|
||||
// execution, and it is fine for OUR repositories and not fine as a general mechanism.
|
||||
//
|
||||
// `[blocker for third-party submissions]` A stranger's plugin cannot be imported by the machine that
|
||||
// serves the catalogue. Whoever builds submissions has to either evaluate manifests in a sandbox or make
|
||||
// authors publish a `manifest.json` alongside. Recorded rather than solved: nobody can submit yet.
|
||||
//
|
||||
// ── Local clones, for now ──
|
||||
//
|
||||
// It syncs from `plugins/` on this machine rather than by cloning each repo into a temp directory. That
|
||||
// keeps tonight's scope to "get real data into the database" — and it means the machine running the sync
|
||||
// must have the plugins checked out. See `docs/marketplace.md` → "Sync" for the version that clones.
|
||||
/**
|
||||
* Fill the marketplace catalogue from the plugins' releases.
|
||||
*
|
||||
* bun --env-file=.env scripts/marketplace-sync.ts every registered plugin
|
||||
* bun --env-file=.env scripts/marketplace-sync.ts photos just one
|
||||
* bun --env-file=.env scripts/marketplace-sync.ts --add <url> register a new one, then sync it
|
||||
* bun --env-file=.env scripts/marketplace-sync.ts --force re-publish releases already published
|
||||
* bun --env-file=.env scripts/marketplace-sync.ts --quiet print only what changed
|
||||
*
|
||||
* All the reasoning is in `src/servers/marketplace/sync.ts` — this file is the command line around it.
|
||||
* It is a thin wrapper on purpose: the same functions are what the Gitea webhook calls, so a plugin
|
||||
* published by hand and one published by pushing a tag go through exactly the same code.
|
||||
*
|
||||
* Re-running is always safe. It overwrites and never merges.
|
||||
*/
|
||||
|
||||
type Sync = { appName: string; developer: string; repoUrl: string };
|
||||
const argv = process.argv.slice(2);
|
||||
/*
|
||||
`--quiet` is what the timer runs: one line per plugin that actually moved, and a summary. Not silence —
|
||||
the summary is always printed, because a log whose last line is old is the only way to tell "nothing to
|
||||
publish" from "the timer died", and that question does come up.
|
||||
*/
|
||||
const quiet = argv.includes('--quiet');
|
||||
const force = argv.includes('--force');
|
||||
const args = argv.filter((a) => a !== '--quiet' && a !== '--force');
|
||||
|
||||
/**
|
||||
* A clone URL the whole internet can use.
|
||||
*
|
||||
* `plugins/example` and `plugins/offscale` have `git@gitea.officer.dev:plugins/<name>.git` remotes,
|
||||
* because whoever set them up had a key on that host. Publishing that to a catalogue would hand every
|
||||
* installer a URL that only works on the machine it was published from — the install fails at
|
||||
* authentication, which reads as "the plugin is broken" rather than "the catalogue is wrong".
|
||||
* Several plugin repos have `git@gitea.officer.dev:plugins/<name>.git` remotes, because whoever set them
|
||||
* up had a key on that host. Registering one of those would hand every installer a URL that only works
|
||||
* on the machine it was published from — the install fails at authentication, which reads as "the plugin
|
||||
* is broken" rather than "the catalogue is wrong".
|
||||
*/
|
||||
export function toHttpsRemote(remote: string): string | null {
|
||||
const trimmed = remote.trim();
|
||||
@@ -60,142 +57,58 @@ export function toHttpsRemote(remote: string): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const git = (dir: string, args: string[]): string | null => {
|
||||
const result = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' });
|
||||
if (result.exitCode !== 0) return null;
|
||||
return new TextDecoder().decode(result.stdout).trim() || null;
|
||||
};
|
||||
|
||||
/*
|
||||
Which plugins directory to publish FROM.
|
||||
|
||||
There is one: `platform/plugins/`, holding what this machine has INSTALLED — updated by
|
||||
`plugins:update`, which is a git pull, so it sits at whatever version was last installed.
|
||||
|
||||
Publishing from it means the catalogue advertises the version this machine happens to be running
|
||||
rather than the version that was released. That is exactly what happened on 2026-08-27: 21 plugins
|
||||
were tagged and the store went on showing 1.0.0.
|
||||
|
||||
A second directory — `officer-dev/plugins/`, every repo in the org cloned side by side — was created
|
||||
to fix that, and never did: its entries were SYMLINKS into this one, so both paths read the same
|
||||
working copy and published the same version. It was removed on 2026-08-28.
|
||||
|
||||
**The problem is still open, and a second checkout was never the answer.** Each directory here is a
|
||||
real git repo with its own tags, so the released manifest can be read out of the tag itself —
|
||||
`git show <newest-tag>:manifest.ts` — without caring what is checked out. That is the fix; it is not
|
||||
written yet, and until it is, this script advertises what is installed.
|
||||
*/
|
||||
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
|
||||
|
||||
async function syncOne(dir: string): Promise<Sync | null> {
|
||||
const appName = basename(dir);
|
||||
const manifestPath = join(dir, 'manifest.ts');
|
||||
if (!existsSync(manifestPath)) {
|
||||
console.warn(`[marketplace-sync] ${appName}: no manifest.ts — skipped`);
|
||||
return null;
|
||||
if (args[0] === '--add') {
|
||||
const raw = args[1];
|
||||
if (!raw) {
|
||||
console.error('usage: marketplace-sync.ts --add <clone url>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const remote = git(dir, ['remote', 'get-url', 'origin']);
|
||||
const repoUrl = remote ? toHttpsRemote(remote) : null;
|
||||
const repoUrl = toHttpsRemote(raw);
|
||||
if (!repoUrl) {
|
||||
console.warn(`[marketplace-sync] ${appName}: no usable https remote (${remote ?? 'none'}) — skipped`);
|
||||
return null;
|
||||
console.error(`[marketplace-sync] ${raw} is not a clone URL this can turn into https`);
|
||||
process.exit(1);
|
||||
}
|
||||
const appName = repoUrl.replace(/\.git$/, '').split('/').pop() ?? '';
|
||||
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(appName)) {
|
||||
console.error(`[marketplace-sync] "${appName}" is not a usable app name`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { manifest } = (await import(manifestPath)) as { manifest: Record<string, unknown> };
|
||||
if (!manifest) {
|
||||
console.warn(`[marketplace-sync] ${appName}: manifest.ts exports no \`manifest\` — skipped`);
|
||||
return null;
|
||||
// Registered with a placeholder developer, then immediately synced — which reads the real publisher
|
||||
// out of the released manifest and overwrites it. A repository with no tag stays registered and
|
||||
// unpublished, which is the correct state for one that has not made a release yet.
|
||||
await upsertPlugin({ appName, developer: 'unknown', repoUrl });
|
||||
console.log(`[marketplace-sync] registered ${appName} → ${repoUrl}`);
|
||||
const one = await syncPlugin(appName, force);
|
||||
report([one]);
|
||||
process.exit(one.status === 'failed' ? 1 : 0);
|
||||
}
|
||||
|
||||
const results = args[0] ? [await syncPlugin(args[0], force)] : await syncAll(force);
|
||||
report(results);
|
||||
process.exit(results.some((r) => r.status === 'failed') ? 1 : 0);
|
||||
|
||||
function report(results: Awaited<ReturnType<typeof syncAll>>): void {
|
||||
const stamp = new Date().toISOString();
|
||||
for (const r of results) {
|
||||
if (r.status === 'synced') console.log(`[marketplace-sync] ${r.appName} ← ${r.tag}`);
|
||||
else if (r.status === 'failed') console.error(`[marketplace-sync] ${r.appName} failed: ${r.error}`);
|
||||
else if (quiet) continue;
|
||||
else if (r.status === 'unchanged') console.log(`[marketplace-sync] ${r.appName} = ${r.tag}`);
|
||||
else console.warn(`[marketplace-sync] ${r.appName}: no tag — nothing published`);
|
||||
}
|
||||
|
||||
// What the plugin SHIPS, read from the directory rather than declared — the same rule discovery
|
||||
// follows, so a catalogue entry cannot claim a sidecar the repository does not have.
|
||||
const has = {
|
||||
api: existsSync(join(dir, 'api', 'router.ts')),
|
||||
schema: existsSync(join(dir, 'db', 'schema.ts')),
|
||||
sidecar: existsSync(join(dir, 'sidecar', 'index.ts')) || existsSync(join(dir, 'sidecar', 'index.mjs')),
|
||||
web: existsSync(join(dir, 'web')),
|
||||
};
|
||||
const synced = results.filter((r) => r.status === 'synced').length;
|
||||
const unchanged = results.filter((r) => r.status === 'unchanged').length;
|
||||
const unreleased = results.filter((r) => r.status === 'unreleased').map((r) => r.appName);
|
||||
const failed = results.filter((r) => r.status === 'failed').map((r) => r.appName);
|
||||
|
||||
const readmePath = ['README.md', 'readme.md'].map((n) => join(dir, n)).find((p) => existsSync(p));
|
||||
const readme = readmePath ? readFileSync(readmePath, 'utf-8') : null;
|
||||
|
||||
const developer = String(manifest.publisher ?? 'unknown');
|
||||
const pluginId = await upsertPlugin({ appName, developer, repoUrl });
|
||||
|
||||
await upsertMetadata(pluginId, {
|
||||
version: String(manifest.version ?? '0.0.0'),
|
||||
platform: String(manifest.platform ?? '>=1.0.0'),
|
||||
label: String(manifest.label ?? appName),
|
||||
summary: String(manifest.summary ?? ''),
|
||||
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
|
||||
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
|
||||
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
|
||||
// quietly disagreeing with the repository it claims to mirror.
|
||||
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
|
||||
color: (manifest.color as string) ?? null,
|
||||
tile: (manifest.tile as string) ?? null,
|
||||
// Artwork lives in the repo, so the URL is built from the remote rather than stored by hand. Only
|
||||
// claimed when the file is actually there — a broken image is worse than a glyph.
|
||||
iconUrl: existsSync(join(dir, 'assets', 'icon.png')) ? rawUrl(repoUrl, 'assets/icon.png') : null,
|
||||
icon: (manifest.icon as string) ?? null,
|
||||
permissions: manifest.permissions ?? [],
|
||||
osDependencies: manifest.osDependencies ?? null,
|
||||
has,
|
||||
readme,
|
||||
sourceCommit: git(dir, ['rev-parse', 'HEAD']),
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[marketplace-sync] ${appName} ← ${repoUrl}${readme ? '' : ' (no README)'}${has.web ? '' : ' (no web/)'}`,
|
||||
);
|
||||
return { appName, developer, repoUrl };
|
||||
console.log(`[marketplace-sync] ${stamp} — ${synced} published, ${unchanged} unchanged, ${unreleased.length} untagged`);
|
||||
// Named rather than counted, and not under `--quiet`: "4 untagged" is a number nobody acts on, but a
|
||||
// timer that reprints the same four names every minute is a log nobody reads. The names belong in a
|
||||
// run somebody asked for.
|
||||
if (unreleased.length && !quiet) {
|
||||
console.warn(`[marketplace-sync] never tagged, so not updated: ${unreleased.join(', ')}`);
|
||||
}
|
||||
if (failed.length) console.error(`[marketplace-sync] failed: ${failed.join(', ')}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw-file URL for a Gitea repository.
|
||||
*
|
||||
* `HEAD`, not a branch name. This said `branch/main` and the comment here warned that "a repo whose
|
||||
* default branch is `master` gets a 404" — which then happened: four plugin repositories were created
|
||||
* on `master` and every one of them served a broken image in the marketplace on any server that reads
|
||||
* the remote catalogue. A warning in a comment is not a guard.
|
||||
*
|
||||
* Gitea resolves `HEAD` to whatever the default branch is, so the question stops being asked. Verified
|
||||
* against both spellings.
|
||||
*
|
||||
* `[open]` Still Gitea-shaped. A GitHub-hosted plugin needs the other form, and that wants the forge to
|
||||
* be part of the registry rather than inferred from the URL.
|
||||
*/
|
||||
function rawUrl(repoUrl: string, path: string): string {
|
||||
return `${repoUrl.replace(/\.git$/, '')}/raw/HEAD/${path}`;
|
||||
}
|
||||
|
||||
const dirs = existsSync(PLUGINS_DIR)
|
||||
? [...new Bun.Glob('*/').scanSync({ cwd: PLUGINS_DIR, onlyFiles: false })]
|
||||
// Not a plugin: the shared install target for plugin npm packages, which lives here because bun
|
||||
// resolves node_modules up the tree. `discoverPlugins` skips it for the same reason.
|
||||
.filter((d) => d.replace(/\/$/, '') !== 'node_modules')
|
||||
.map((d) => join(PLUGINS_DIR, d))
|
||||
: [];
|
||||
|
||||
if (dirs.length === 0) {
|
||||
console.log(`[marketplace-sync] no plugin directories under ${PLUGINS_DIR} — nothing to sync`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Discovery is not used to FIND them — a plugin that fails discovery is exactly the kind of thing a
|
||||
// catalogue should still be able to describe — but its broken list is worth surfacing here.
|
||||
const { broken } = await discoverPlugins().catch(() => ({ broken: [] as { appName: string; error: string }[] }));
|
||||
for (const { appName, error } of broken) console.warn(`[marketplace-sync] ${appName} is broken on disk: ${error}`);
|
||||
|
||||
const synced: Sync[] = [];
|
||||
for (const dir of dirs) {
|
||||
const result = await syncOne(dir).catch((err) => {
|
||||
console.error(`[marketplace-sync] ${basename(dir)} failed: ${err instanceof Error ? err.message : err}`);
|
||||
return null;
|
||||
});
|
||||
if (result) synced.push(result);
|
||||
}
|
||||
|
||||
console.log(`[marketplace-sync] ${synced.length}/${dirs.length} plugin(s) in the catalogue`);
|
||||
process.exit(0);
|
||||
|
||||
@@ -8,7 +8,17 @@ import { useInitialData } from '@/state/useInitialData';
|
||||
// `installedPlugins`, not `plugins`: App.tsx already destructures a `plugins` from useServerSettings(),
|
||||
// which is the DEAD plugin system — /server-settings/plugins scans src/workspaces/plugins/, a directory
|
||||
// that does not exist, so it is always []. Different thing entirely; see plugins/offscale/PLUGIN.md.
|
||||
import { plugins as installedPlugins } from './Plugins.gen';
|
||||
import { plugins as installedPlugins, pluginFileActions } from './Plugins.gen';
|
||||
import { registerFileActions } from 'officerdev';
|
||||
|
||||
/*
|
||||
Plugin actions for the file browser's context menu, handed over once at module load.
|
||||
|
||||
Injected rather than imported by the browser itself: `Plugins.gen.tsx` is generated INTO this app, and
|
||||
the file browser lives in a workspace package this app depends on. Importing it there would point the
|
||||
dependency backwards and make that package unusable outside Officer's own frontend.
|
||||
*/
|
||||
registerFileActions(pluginFileActions);
|
||||
|
||||
/**
|
||||
* `/plugins` → `/marketplace`, KEEPING the query string.
|
||||
@@ -87,20 +97,34 @@ export function App() {
|
||||
404 on a route that worked yesterday reads as the feature having been removed. */}
|
||||
<Route path="/plugins" element={<PluginsRedirect />} />
|
||||
{/* 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
|
||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
|
||||
what is installed, because a bundler cannot follow a runtime import specifier. */}
|
||||
{/* One route 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`. */}
|
||||
{/*
|
||||
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) => [
|
||||
<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
|
||||
key={`${plugin.screenId}-section`}
|
||||
path={`${plugin.route}/:section`}
|
||||
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/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||
|
||||
@@ -14,6 +14,14 @@ export type CatalogueEntry = {
|
||||
appName: string;
|
||||
publisher: string;
|
||||
version: string;
|
||||
/**
|
||||
* The git tag this entry was published from.
|
||||
*
|
||||
* Optional on the wire, not in the database, and the two mean different things. Here it is absent for
|
||||
* a row that predates release-based publishing OR for a catalogue too old to send the field at all —
|
||||
* so a consumer must treat "no tag" as "cannot tell" and fall back to whatever it knew before.
|
||||
*/
|
||||
releaseTag?: string;
|
||||
platform: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
@@ -52,6 +60,7 @@ function toCatalogueEntry(row: Row): CatalogueEntry | null {
|
||||
appName: row.appName,
|
||||
publisher: row.developer,
|
||||
version: m.version,
|
||||
...(m.releaseTag ? { releaseTag: m.releaseTag } : {}),
|
||||
platform: m.platform,
|
||||
label: m.label,
|
||||
summary: m.summary,
|
||||
@@ -106,6 +115,36 @@ export async function catalogueEntry(appName: string): Promise<CatalogueEntry |
|
||||
return { ...entry, ...(row.metadata.readme ? { readme: row.metadata.readme } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A registry row, plus the one piece of metadata the sync needs before deciding to do any work.
|
||||
*
|
||||
* `sourceCommit` is what the last sync published, and it is here rather than fetched per plugin so a
|
||||
* run that has nothing to do costs one query instead of twenty-five. Null means never synced.
|
||||
*/
|
||||
export type RegistryRow = { id: number; appName: string; repoUrl: string; sourceCommit: string | null };
|
||||
|
||||
/**
|
||||
* Every registered plugin — identity and clone URL, no metadata.
|
||||
*
|
||||
* This is what the sync iterates, and it is the reason the registry is called authoritative: the
|
||||
* catalogue lists what somebody deliberately registered, not what happens to be checked out on the
|
||||
* machine that runs the job. Unlisted rows are included; hiding a plugin from the public API is not the
|
||||
* same as no longer tracking it, and one taken down for a bad release has to be able to come back
|
||||
* current.
|
||||
*/
|
||||
export async function listRegistry(): Promise<RegistryRow[]> {
|
||||
return marketplaceDb
|
||||
.select({
|
||||
id: plugins.id,
|
||||
appName: plugins.appName,
|
||||
repoUrl: plugins.repoUrl,
|
||||
sourceCommit: pluginMetadata.sourceCommit,
|
||||
})
|
||||
.from(plugins)
|
||||
.leftJoin(pluginMetadata, eq(pluginMetadata.pluginId, plugins.id))
|
||||
.orderBy(plugins.appName);
|
||||
}
|
||||
|
||||
// ── Writes. Used by the sync script, never by the public API ──
|
||||
|
||||
type RegisterParams = { appName: string; developer: string; repoUrl: string };
|
||||
|
||||
@@ -73,6 +73,19 @@ export const pluginMetadata = pgTable(
|
||||
.references(() => plugins.id, { onDelete: 'cascade' }),
|
||||
|
||||
version: text('version').notNull(),
|
||||
/**
|
||||
* The git tag this row was read from — what "published" actually means.
|
||||
*
|
||||
* `version` above is the manifest's own claim, and the two are the same string every time somebody
|
||||
* tags correctly. They are kept apart because when they DISAGREE the tag is the fact: it is what was
|
||||
* pushed, and a manifest whose version nobody bumped would otherwise make a real release invisible
|
||||
* to anything comparing versions.
|
||||
*
|
||||
* Nullable because rows existed before releases were the unit of publishing. A null one is a
|
||||
* snapshot taken from a working tree by the old sync, and every consumer should read it as "unknown"
|
||||
* rather than "no release".
|
||||
*/
|
||||
releaseTag: text('release_tag'),
|
||||
/** The platform range it declares support for, e.g. `>=1.0.0`. */
|
||||
platform: text('platform').notNull(),
|
||||
label: text('label').notNull(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { pluginInstalls } from './schema';
|
||||
import { pluginInstalls, pluginPorts } from './schema';
|
||||
|
||||
export type PluginInstall = typeof pluginInstalls.$inferSelect;
|
||||
|
||||
@@ -47,3 +47,90 @@ export async function removePluginInstall(appName: string): Promise<boolean> {
|
||||
const rows = await db.delete(pluginInstalls).where(eq(pluginInstalls.appName, appName)).returning();
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
// ── Ports the platform hands out and remembers ────────────────────────────────────────────────────
|
||||
|
||||
export type PluginPort = typeof pluginPorts.$inferSelect;
|
||||
|
||||
/** Every allocation, for the settings screen and for anyone asking what is spoken for. */
|
||||
export async function listPluginPorts(): Promise<PluginPort[]> {
|
||||
return db.select().from(pluginPorts).orderBy(pluginPorts.port);
|
||||
}
|
||||
|
||||
/** What this plugin has been given, as `{ name: port }`. Empty when it has asked for nothing. */
|
||||
export async function getPluginPorts(appName: string): Promise<Record<string, number>> {
|
||||
const rows = await db.select().from(pluginPorts).where(eq(pluginPorts.appName, appName));
|
||||
return Object.fromEntries(rows.map((r) => [r.name, r.port]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a port nothing is using, by asking the kernel and then checking our own book.
|
||||
*
|
||||
* Binding `port: 0` is the only honest way to know a port is free — a list of what we have handed out
|
||||
* says nothing about what else on this machine is listening, and a hardcoded range says nothing about
|
||||
* either. So the kernel picks, we let go, and the caller binds it a moment later.
|
||||
*
|
||||
* ── The gap between letting go and binding ──
|
||||
*
|
||||
* Real, and unavoidable without holding the socket open across a process boundary. Something else can
|
||||
* take the port in between. That is why allocation is not the last word: a sidecar that cannot bind its
|
||||
* remembered port must say so loudly rather than quietly choosing another, because the number is written
|
||||
* down in configuration this database cannot see.
|
||||
*/
|
||||
async function freePort(taken: Set<number>): Promise<number> {
|
||||
for (let attempt = 0; attempt < 20; attempt++) {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
const port = probe.port;
|
||||
probe.stop(true);
|
||||
if (port && !taken.has(port)) return port;
|
||||
}
|
||||
throw new Error('could not find a free port after 20 attempts');
|
||||
}
|
||||
|
||||
/**
|
||||
* The port this plugin was given for `name`, allocating one the first time.
|
||||
*
|
||||
* Idempotent and stable: the same plugin and name always gets the same number back, which is the entire
|
||||
* point — see the note on `pluginPorts`. Re-installing, upgrading and restarting all return what was
|
||||
* given the first time.
|
||||
*/
|
||||
export async function allocatePluginPort(appName: string, name: string): Promise<number> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(pluginPorts)
|
||||
.where(and(eq(pluginPorts.appName, appName), eq(pluginPorts.name, name)));
|
||||
if (existing) return existing.port;
|
||||
|
||||
/*
|
||||
Racing another install is possible and is handled by the unique index rather than by a lock: two
|
||||
installs running at once can be handed the same number by two different kernel probes, and exactly
|
||||
one of the inserts will win. The loser tries again with a fresh number rather than failing, because
|
||||
"somebody else installed a plugin at the same moment" is not a reason to refuse.
|
||||
*/
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const taken = new Set((await db.select().from(pluginPorts)).map((r) => r.port));
|
||||
const port = await freePort(taken);
|
||||
try {
|
||||
const [row] = await db.insert(pluginPorts).values({ appName, name, port }).returning();
|
||||
return row!.port;
|
||||
} catch {
|
||||
const [now] = await db
|
||||
.select()
|
||||
.from(pluginPorts)
|
||||
.where(and(eq(pluginPorts.appName, appName), eq(pluginPorts.name, name)));
|
||||
if (now) return now.port; // the other writer was allocating the same thing
|
||||
}
|
||||
}
|
||||
throw new Error(`could not allocate a port for ${appName}/${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Give up a plugin's ports. NOT called on uninstall — see the note in schema.ts.
|
||||
*
|
||||
* The number lives in configuration this database cannot see, so handing it to something else because a
|
||||
* plugin was briefly removed points that configuration at the wrong service. Only `purge` does this.
|
||||
*/
|
||||
export async function releasePluginPorts(appName: string): Promise<number> {
|
||||
const rows = await db.delete(pluginPorts).where(eq(pluginPorts.appName, appName)).returning();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { pgTable, serial, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, serial, text, integer, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
// Which plugins are installed on this machine, and whether they should be mounted.
|
||||
//
|
||||
@@ -56,3 +56,57 @@ export const pluginInstalls = pgTable(
|
||||
uniqueIndex('uq_plugin_installs_app_name').on(t.appName),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Ports the platform has given out to plugins, and remembers.
|
||||
*
|
||||
* ── Why the platform allocates these when it allocates nothing else ──
|
||||
*
|
||||
* A sidecar's own port is not allocated: it binds `port: 0`, the kernel picks one, and it announces the
|
||||
* number over the registration socket. That is right for what it is — a private detail between two
|
||||
* processes, on loopback, re-chosen every restart, and nothing outside Officer ever needs to know it.
|
||||
*
|
||||
* A port something ELSE connects to is a different thing entirely. A plugin serving a site has to be
|
||||
* reachable from nginx, which means the number must be stable across restarts and must be knowable by a
|
||||
* human writing a proxy configuration. Ephemeral fails both.
|
||||
*
|
||||
* Doing it here rather than in each plugin's own settings buys the one thing per-plugin config cannot:
|
||||
* **two plugins cannot be given the same number**, and the platform knows which are spoken for. That is
|
||||
* `uq_plugin_ports_port` below, and it is the whole argument for this table.
|
||||
*
|
||||
* ── Kept when a plugin is uninstalled ──
|
||||
*
|
||||
* Deliberate. The number is written down somewhere this database cannot see — an nginx proxy host, a
|
||||
* firewall rule, somebody's notes — so handing it to a different plugin because this one was briefly
|
||||
* removed would point that configuration at the wrong service. Reinstalling gets the same port back.
|
||||
* `purge` is the deliberate act that gives it up.
|
||||
*/
|
||||
export const pluginPorts = pgTable(
|
||||
'plugin_ports',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
/** The plugin's app name — its directory, as everywhere else. */
|
||||
appName: text('app_name').notNull(),
|
||||
/**
|
||||
* Which of the plugin's ports this is, as the manifest named it.
|
||||
*
|
||||
* A plugin may want more than one — a site and a preview of it — so the number alone is not an
|
||||
* identity. Reaches the sidecar as `PORT_<NAME>` in its environment, uppercased.
|
||||
*/
|
||||
name: text('name').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
allocatedAt: timestamp('allocated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
// One allocation per named port per plugin.
|
||||
uniqueIndex('uq_plugin_ports_app_name_name').on(t.appName, t.name),
|
||||
/*
|
||||
No two plugins on the same number, said structurally.
|
||||
|
||||
This is the reason the table exists rather than a convention people follow. A collision here is not
|
||||
a duplicate row, it is two services silently fighting over a listener, where the loser fails to bind
|
||||
at startup and the winner is whichever happened to start first.
|
||||
*/
|
||||
uniqueIndex('uq_plugin_ports_port').on(t.port),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5,6 +5,8 @@ import { join } from 'node:path';
|
||||
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
|
||||
import { assertPermissionTotality } from './servers/permissions/totality';
|
||||
import { bootstrapCaldav } from './servers/caldav-bootstrap';
|
||||
import { registerThumbnailWarmer } from './servers/events/warm-thumbnails';
|
||||
import { registerPluginHooks } from './servers/events/plugin-hooks';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
|
||||
import { listPluginRootClaims } from './servers/plugins/public-paths';
|
||||
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
|
||||
@@ -252,6 +254,19 @@ if (secured.repaired.length) {
|
||||
}
|
||||
for (const path of secured.failed) console.error(`[boot] could not close ${path} — check its owner and mode`);
|
||||
|
||||
/*
|
||||
Subscribe to platform events before anything can emit one.
|
||||
|
||||
Both are pure registration — no I/O, nothing that can fail — so they sit above the plugin mount rather
|
||||
than inside its try. If either threw, the server not starting would be the correct outcome, and
|
||||
wrapping them in the plugin block's `catch` would hide that.
|
||||
|
||||
The thumbnail warmer is core subscribing to core: the cache belongs to the platform, so the work has to
|
||||
happen here. Plugin hooks are the general mechanism, resolved per event from whatever is installed.
|
||||
*/
|
||||
registerThumbnailWarmer();
|
||||
registerPluginHooks();
|
||||
|
||||
// Mount whatever is installed, BEFORE serve().
|
||||
//
|
||||
// Without this a restart silently unmounts every plugin: `honoServer` is built with none at import,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { ANTHROPIC_PROXY_URL } from './officer-url.mjs';
|
||||
import { readProxySecretFromDisk } from './sidecar/claude/state';
|
||||
|
||||
/**
|
||||
* The sanctioned way for PLATFORM-SIDE code — including a plugin's server routes — to call Claude.
|
||||
*
|
||||
* ── Why this module exists ──
|
||||
*
|
||||
* `officer-anthropic-proxy` swaps a local secret for the owner's OAuth bearer, and that secret lives
|
||||
* in `sidecar/claude-state.json` — platform-internal state whose location, format and permissions are
|
||||
* this repo's to change. The first caller outside the sidecar (fofos-website's translate route) read
|
||||
* the file directly, which worked and meant the sidecar could no longer move its own state without
|
||||
* breaking a plugin. This module is the boundary: plugins import IT, and only platform code touches
|
||||
* the file.
|
||||
*
|
||||
* ── Why a module and not an HTTP route ──
|
||||
*
|
||||
* Every caller so far is server-side code in this same process, already behind its own gate. An HTTP
|
||||
* endpoint that spends the owner's subscription would be NEW surface — reachable by anything holding a
|
||||
* session, needing its own registry entry and permission — bought for no caller that exists. The day
|
||||
* a browser-side caller appears, the route is a thin wrapper over this plus that registry entry.
|
||||
*
|
||||
* ── The secret is read per call ──
|
||||
*
|
||||
* A fresh install writes it through a 30s debounce, so anything that cached at boot could hold an
|
||||
* empty string forever. Reading late costs one small file read and removes the failure mode.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A client pointed at the proxy, or null when the secret is not on disk yet.
|
||||
*
|
||||
* Null rather than a throw, because "not yet" is an ordinary state on a fresh install — the caller
|
||||
* says which process is missing, in its own words, with its own status code.
|
||||
*/
|
||||
/*
|
||||
Re-exported so a caller's `instanceof` works.
|
||||
|
||||
A plugin that imported '@anthropic-ai/sdk' itself could resolve a SECOND copy from the plugins'
|
||||
own node_modules — and an error thrown by the platform's copy is not an instance of the other
|
||||
one's classes, so `catch (e) { e instanceof RateLimitError }` silently stops matching. One import
|
||||
path, one copy, one class identity.
|
||||
*/
|
||||
export { default as Anthropic } from '@anthropic-ai/sdk';
|
||||
|
||||
export function anthropicViaProxy(): Anthropic | null {
|
||||
const secret = readProxySecretFromDisk();
|
||||
if (!secret) return null;
|
||||
return new Anthropic({ apiKey: secret, baseURL: ANTHROPIC_PROXY_URL });
|
||||
}
|
||||
@@ -41,9 +41,12 @@ import {
|
||||
setCommanderSessionId,
|
||||
listCommandHqSessions,
|
||||
getCommandHqSession,
|
||||
getCommandHqSessionById,
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
deleteCommandHqSession,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
@@ -721,9 +724,9 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
* `name: string` — rename. Proxies straight to `renameClaudeSession`, the SAME function `/chat`'s own
|
||||
* rename action calls — there is no separate name field on this table to disagree with it.
|
||||
* `watcherArmed: false` — disarm a watcher, so the harness stops re-arming it on its next fire. Only
|
||||
* `false` is accepted here: arming requires a live task id (`armCommandHqWatcher`), which only the turn
|
||||
* that starts the watcher has — there is no product path to arm one from outside a running turn, and
|
||||
* this route is not it.
|
||||
* `false` is accepted here: arming through THIS route would need a live task id, which only the turn
|
||||
* that starts the watcher has. A loop reporting its own task id arms through `POST .../dir/:dirName/arm`
|
||||
* below instead — that route exists precisely because this one can't take it.
|
||||
*/
|
||||
commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -751,6 +754,88 @@ commandHqRouter.patch('/commanders/:sessionId', async (ctx) => {
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* The self-report counterpart to `armCommandHqWatcher`/`recordCommandHqWatcherFired`'s harness-driven
|
||||
* calls in `websocket.ts`. Those fire off a `task:started`/`task:notification` event from the harness's
|
||||
* OWN background-task tracking on the live session that submitted the arm dialog — which never happens
|
||||
* for a loop that outlives its turn (`setsid`/`nohup`, or a tracked background job that dies with the
|
||||
* turn anyway, which is most of them). `watcher_armed` stayed false and `command_hq_watcher_fires` stayed
|
||||
* empty forever for exactly that class of commander — see `docs/command-hq.md` → "Self-reported arm".
|
||||
*
|
||||
* Authenticated with the commander's own `OFFICER_API_KEY` (minted per-commander in `credential.ts`), the
|
||||
* same door its loop already uses for `/api/notify` and `/api/tasks/*/run`. Keyed on `:dirName`, not
|
||||
* `:sessionId` — a loop knows its own directory name (it's the folder it's running from; `dirName` is
|
||||
* literally `String(id)`, see `createCommander`) and has no reason to also know its transcript's uuid.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/arm', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row?.sessionId) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (!taskId) throw errors.BAD_REQUEST('taskId required');
|
||||
|
||||
await armCommandHqWatcher(user.id, row.sessionId, taskId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* `fire`'s own guard mirrors the harness-driven path exactly: only records a trigger when this row is
|
||||
* CURRENTLY armed with a matching task id, so an old instance of a loop that a fresh restart superseded
|
||||
* (new pid, new self-generated task id) can't keep writing fires for a watch it no longer is — the same
|
||||
* protection `websocket.ts`'s `task:notification` handler gets from checking `watcherTaskId === taskId`.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/fire', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (!taskId) throw errors.BAD_REQUEST('taskId required');
|
||||
|
||||
if (!row.watcherArmed || row.watcherTaskId !== taskId) {
|
||||
return ctx.json({ ok: false, reason: 'not the current armed task' });
|
||||
}
|
||||
await recordCommandHqWatcherFired(row.id, taskId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* The loop's own clean-exit counterpart to `arm` — best-effort, same as every other disarm path: a loop
|
||||
* killed without running its trap still leaves `watcher_armed: true` until the next arm or an explicit
|
||||
* disarm, exactly as it already could before this route existed.
|
||||
*
|
||||
* Takes the SAME `taskId` guard as `fire`, and for the same reason: a restart replaces the old instance
|
||||
* with a fresh arm before the old one's process necessarily notices it's been superseded (an inotify
|
||||
* loop's kill can outrace the new one's `arm` call by seconds). An unguarded disarm from the OLD
|
||||
* instance's exit trap would then clear the NEW instance's `watcher_armed` a moment after it was set —
|
||||
* caught in exactly this shape while restarting a real duplicate on this machine, 2026-09-14. `taskId`
|
||||
* is optional so an out-of-band disarm (nothing self-reported ever armed this row) still works — the
|
||||
* guard only applies once there is a task id on the row to disagree with.
|
||||
*/
|
||||
commandHqRouter.post('/commanders/dir/:dirName/disarm', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const id = Number(ctx.req.param('dirName'));
|
||||
if (!Number.isInteger(id)) throw errors.NOT_FOUND('not a commander');
|
||||
const row = await getCommandHqSessionById(user.id, id);
|
||||
if (!row?.sessionId) throw errors.NOT_FOUND('not a commander');
|
||||
|
||||
const body = await ctx.req.json<{ taskId?: string }>().catch(() => ({}) as { taskId?: string });
|
||||
const taskId = body.taskId?.trim();
|
||||
if (taskId && row.watcherTaskId && row.watcherTaskId !== taskId) {
|
||||
return ctx.json({ ok: false, reason: 'not the current armed task' });
|
||||
}
|
||||
|
||||
await disarmCommandHqWatcher(user.id, row.sessionId);
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /command-hq/commanders/:sessionId
|
||||
*
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 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>'));
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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")`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,38 @@ const normalisePath = (path: string): string => {
|
||||
/** `/x` → `<base>/x`, with `..` already collapsed. Never touches `//host` or a full URL. */
|
||||
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) =>
|
||||
html
|
||||
.replace(/<head([^>]*)>/i, `<head$1>${proxyOverrideScript(base)}`)
|
||||
@@ -97,12 +128,52 @@ export const rewriteHtml = (html: string, base: string) =>
|
||||
/(href|src|action)=("|')(\/(?!\/)[^"']*)\2/gi,
|
||||
(_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
|
||||
// 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
|
||||
// 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/`);
|
||||
|
||||
/*
|
||||
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));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { resolveAuthToken } from '../../auth-token';
|
||||
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.
|
||||
@@ -145,7 +145,8 @@ devProxyRouter.all('/:port/*', async (ctx) => {
|
||||
|
||||
const type = res.headers.get('content-type') ?? '';
|
||||
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);
|
||||
// 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 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.
|
||||
headers.delete('content-length');
|
||||
headers.delete('content-encoding');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
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 { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
@@ -11,6 +12,7 @@ import { readOcrConfig } from '@@/api/server-settings/ocr';
|
||||
import { getUserSettings } from 'officerdb';
|
||||
import { buildThumbnail, clampSize, thumbnailKind } from './thumbnails';
|
||||
import { pluginFileTypes, sidecarFetch } from './plugin-file-types';
|
||||
import { emit } from '../../events/bus';
|
||||
|
||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
try {
|
||||
@@ -187,9 +189,17 @@ router.get('/ls', async (ctx) => {
|
||||
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 {
|
||||
names = await readdir(absPath);
|
||||
names = await readdir(absPath, { withFileTypes: true });
|
||||
} catch (ex) {
|
||||
// A missing directory resets the browser to the root, which is the right answer for a stale path.
|
||||
//
|
||||
@@ -212,18 +222,44 @@ router.get('/ls', async (ctx) => {
|
||||
const claimed = await pluginFileTypes();
|
||||
|
||||
const entries = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
names.map(async (entry) => {
|
||||
const name = entry.name;
|
||||
const fullPath = resolve(absPath, name);
|
||||
// Skip entries that escape the home dir (shouldn't happen but be safe)
|
||||
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);
|
||||
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 {
|
||||
name,
|
||||
type: isDirectory ? 'directory' : 'file',
|
||||
size: s.size,
|
||||
modifiedAt: s.mtimeMs,
|
||||
size: own.size,
|
||||
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
|
||||
rather than one on each side of the wire.
|
||||
@@ -333,6 +369,7 @@ router.post('/upload', async (ctx) => {
|
||||
going to be, next to where it belongs. Nothing sweeps them up — a job deleting files it did not
|
||||
create is a worse trade than a fragment somebody can see.
|
||||
*/
|
||||
const written: string[] = [];
|
||||
for (const file of files) {
|
||||
if (!(file instanceof File)) continue;
|
||||
const filePath = resolve(targetDir, file.name);
|
||||
@@ -340,8 +377,19 @@ router.post('/upload', async (ctx) => {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
|
||||
await writeAtomic(filePath, file);
|
||||
written.push(filePath);
|
||||
}
|
||||
|
||||
/*
|
||||
Announced AFTER the writes and BEFORE the response, which is the only ordering that is correct.
|
||||
|
||||
After, because a subscriber reads these from disk and they have to be there. Before returning is
|
||||
fine because `emit` schedules and returns — the upload does not wait for anybody. What it must never
|
||||
become is an await: a subscriber thumbnailing a HEIC would add five seconds to an upload that has
|
||||
already finished.
|
||||
*/
|
||||
emit('files:uploaded', { paths: written, email: user.email });
|
||||
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
|
||||
@@ -385,6 +433,16 @@ router.post('/rename', async (ctx) => {
|
||||
*
|
||||
* The cache key contains the file's mtime and size, so this URL's answer genuinely cannot change: a
|
||||
* different file is a different URL. That is what makes a year-long cache correct rather than a bet.
|
||||
*
|
||||
* It was only ever half true, and the half that was missing is the half a browser can see. The DISK key
|
||||
* has always held mtime and size, so the server regenerated correctly every time. The URL held the path
|
||||
* and the box size and nothing else — so a browser told to keep the answer for a year kept it for a
|
||||
* year, and a file replaced or renamed into a name already drawn showed the picture that used to be
|
||||
* there. Nothing cleared it but somebody who knew to hard-reload.
|
||||
*
|
||||
* The client now sends `v=<mtime>-<size>` — see `getThumbUrl`. Nothing here reads it: this route stats
|
||||
* the file anyway, and a parameter the server depended on would be a second source of truth about which
|
||||
* file is meant. It is in the URL to be part of the URL, which is the whole job.
|
||||
*/
|
||||
router.get('/thumb', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
@@ -1235,6 +1293,25 @@ router.post('/extract', async (ctx) => {
|
||||
throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed');
|
||||
}
|
||||
|
||||
/*
|
||||
What actually came out, walked once rather than guessed from the archive listing.
|
||||
|
||||
Bounded, because an archive can hold a hundred thousand files and this runs on the request's way
|
||||
out. The warmer has its own cap too; this one keeps the WALK from being the expensive part.
|
||||
*/
|
||||
const unpacked: string[] = [];
|
||||
const collect = async (dir: string, depth = 0): Promise<void> => {
|
||||
if (depth > 8 || unpacked.length >= 500) return;
|
||||
for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
||||
if (unpacked.length >= 500) return;
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) await collect(full, depth + 1);
|
||||
else unpacked.push(full);
|
||||
}
|
||||
};
|
||||
await collect(finalDest);
|
||||
emit('archive:extracted', { paths: unpacked, email: user.email });
|
||||
|
||||
const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
|
||||
return ctx.json({ extractedPath });
|
||||
});
|
||||
|
||||
@@ -138,6 +138,19 @@ const release = () => {
|
||||
* file another chance after somebody has installed a codec or replaced the file. Bounded, because a
|
||||
* long-lived process browsing a lot of broken media should not grow without limit.
|
||||
*/
|
||||
/**
|
||||
* Builds in flight, by cache key — so the same thumbnail is never made twice at once.
|
||||
*
|
||||
* Two callers want the same file all the time now: the warmer starts on upload, and the browser asks
|
||||
* for it a moment later. Without this they both decode the same 4032x3024 HEIC, take a slot each from a
|
||||
* limit of four, and finish at the same time having done the work twice — which turns a warmer meant to
|
||||
* help into one competing with the person waiting.
|
||||
*
|
||||
* Keyed on the same string the cache is, so it deduplicates exactly what would collide: same file, same
|
||||
* size, same content. Removed in a `finally`, or one failure would wedge that key until a restart.
|
||||
*/
|
||||
const inFlight = new Map<string, Promise<ThumbnailResult>>();
|
||||
|
||||
const failed = new Set<string>();
|
||||
const FAILED_LIMIT = 5_000;
|
||||
|
||||
@@ -245,34 +258,39 @@ export async function buildThumbnail(
|
||||
const cached = await stat(cachePath).catch(() => null);
|
||||
if (cached?.isFile() && cached.size > 0) return { ok: true, path: cachePath };
|
||||
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await acquire();
|
||||
try {
|
||||
/*
|
||||
// Somebody else is already making this one. Wait for theirs rather than starting a second.
|
||||
const running = inFlight.get(key);
|
||||
if (running) return running;
|
||||
|
||||
const work = (async (): Promise<ThumbnailResult> => {
|
||||
await mkdir(cacheDir, { recursive: true });
|
||||
await acquire();
|
||||
try {
|
||||
/*
|
||||
`force_original_aspect_ratio=decrease` fits inside the box without cropping, and never enlarges
|
||||
past the source — a 40px icon blown up to 256 is worse than a small one. `-2` on the odd dimension
|
||||
keeps it even, which the JPEG encoder requires for chroma subsampling.
|
||||
*/
|
||||
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
|
||||
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
|
||||
const scale = `scale=${size}:${size}:force_original_aspect_ratio=decrease`;
|
||||
const common = ['-v', 'error', '-y', '-frames:v', '1', '-vf', scale, '-f', 'mjpeg', cachePath];
|
||||
|
||||
let ok: boolean;
|
||||
if (produce) {
|
||||
const bytes = await produce().catch(() => null);
|
||||
if (bytes?.length) await Bun.write(cachePath, bytes);
|
||||
ok = !!bytes?.length;
|
||||
} else if (kind === 'image') {
|
||||
ok = await resizeImage(absPath, cachePath, size);
|
||||
} else {
|
||||
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
|
||||
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
|
||||
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
|
||||
// broken file. Take the first frame instead before giving up on it.
|
||||
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
|
||||
}
|
||||
let ok: boolean;
|
||||
if (produce) {
|
||||
const bytes = await produce().catch(() => null);
|
||||
if (bytes?.length) await Bun.write(cachePath, bytes);
|
||||
ok = !!bytes?.length;
|
||||
} else if (kind === 'image') {
|
||||
ok = await resizeImage(absPath, cachePath, size);
|
||||
} else {
|
||||
// `-ss` BEFORE `-i` is the fast seek — it jumps rather than decoding a second of video first.
|
||||
ok = await spawnFfmpeg(['-ss', SEEK_SECONDS, '-i', absPath, ...common], cachePath);
|
||||
// A clip shorter than the seek yields no frame, and that is a fact about its length rather than a
|
||||
// broken file. Take the first frame instead before giving up on it.
|
||||
if (!ok) ok = await spawnFfmpeg(['-i', absPath, ...common], cachePath);
|
||||
}
|
||||
|
||||
if (!ok) {
|
||||
/*
|
||||
if (!ok) {
|
||||
/*
|
||||
A PRODUCER'S FAILURE IS NOT REMEMBERED, and that distinction is the whole point of this branch.
|
||||
|
||||
When sharp or ffmpeg fails, the file is the problem — it will fail identically forever, and
|
||||
@@ -284,12 +302,20 @@ export async function buildThumbnail(
|
||||
the SERVER was restarted — no client retry could ever get past it, because the answer was being
|
||||
served from memory without the plugin being asked again.
|
||||
*/
|
||||
if (!produce) rememberFailure(key);
|
||||
return { ok: false, reason: 'could not read an image from this file' };
|
||||
}
|
||||
if (!produce) rememberFailure(key);
|
||||
return { ok: false, reason: 'could not read an image from this file' };
|
||||
}
|
||||
|
||||
return { ok: true, path: cachePath };
|
||||
return { ok: true, path: cachePath };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.set(key, work);
|
||||
try {
|
||||
return await work;
|
||||
} finally {
|
||||
release();
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { isSuperAdmin } from '../../super-admin';
|
||||
import { mountPrefix } from '../../plugins/manifest';
|
||||
import { snapshotPlugins } from '../../plugins/mount';
|
||||
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
|
||||
import { fetchCatalogue, fetchReadme } from '../../plugins/marketplace';
|
||||
import { fetchCatalogue, fetchReadme, publishedTags } from '../../plugins/marketplace';
|
||||
import { findOrphanTables } from '../../plugins/orphans';
|
||||
import { fetchPlugin } from '../../plugins/fetch';
|
||||
import { discoverPlugins } from '../../plugins/discover';
|
||||
@@ -57,7 +57,9 @@ pluginsRouter.get('/catalogue/:appName/readme', async (ctx) => {
|
||||
});
|
||||
|
||||
pluginsRouter.get('/', async (ctx) => {
|
||||
const { states, broken } = await snapshotPlugins();
|
||||
// The catalogue, for the update badge — cached, never throws, and falls back to the git rule per
|
||||
// plugin when the store has nothing to say. See `plugins/release.ts`.
|
||||
const { states, broken } = await snapshotPlugins(await publishedTags());
|
||||
// Asked per plugin rather than once, because `pm2 jlist` is a fork and most plugins have no sidecar to
|
||||
// ask about. A plugin that is installed and enabled but whose process is not online is the state worth
|
||||
// rendering differently — it is the difference between "off" and "broken".
|
||||
|
||||
@@ -59,7 +59,23 @@ export async function isKnownToPm2(name: string, cwd: string): Promise<boolean>
|
||||
*/
|
||||
export async function startProcess(name: string, cwd: string): Promise<Pm2Result> {
|
||||
const known = await isKnownToPm2(name, cwd);
|
||||
const args = known ? ['start', name] : ['start', ECOSYSTEM, '--only', name];
|
||||
/*
|
||||
Through the ECOSYSTEM FILE either way, and that is the whole point of this line.
|
||||
|
||||
`pm2 start <name>` restarts a known process with the environment PM2 already holds for it, and never
|
||||
looks at the file again. That was fine while an entry was just a script path — it is not now that an
|
||||
entry carries `env`, because a plugin whose allocated port changed would have its new number written
|
||||
to the file and go on running with the old one.
|
||||
|
||||
Seen exactly once, on the day ports were added: the ecosystem said PORT_SITE=34507, the process was
|
||||
serving 3011, and nothing anywhere reported a disagreement.
|
||||
|
||||
`restart <file> --only <name>` re-reads the entry; `--update-env` makes PM2 replace the stored
|
||||
environment rather than merge into it, so a variable REMOVED from the entry actually goes away.
|
||||
*/
|
||||
const args = known
|
||||
? ['restart', ECOSYSTEM, '--only', name, '--update-env']
|
||||
: ['start', ECOSYSTEM, '--only', name];
|
||||
|
||||
const { code, out } = await pm2(args, cwd);
|
||||
if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` };
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { emit, listenerCount, on } from './bus';
|
||||
|
||||
const settle = () => new Promise((r) => setTimeout(r, 10));
|
||||
|
||||
test('a listener is told, with the payload', async () => {
|
||||
const seen: string[][] = [];
|
||||
const off = on('files:uploaded', (p) => void seen.push(p.paths));
|
||||
emit('files:uploaded', { paths: ['/a', '/b'], email: 'x@y' });
|
||||
await settle();
|
||||
expect(seen).toEqual([['/a', '/b']]);
|
||||
off();
|
||||
});
|
||||
|
||||
test('unsubscribing stops it', async () => {
|
||||
let calls = 0;
|
||||
const off = on('files:uploaded', () => void calls++);
|
||||
off();
|
||||
emit('files:uploaded', { paths: ['/a'], email: 'x@y' });
|
||||
await settle();
|
||||
expect(calls).toBe(0);
|
||||
});
|
||||
|
||||
// The property the whole design rests on: a subscriber must never be able to fail the operation that
|
||||
// triggered it. An upload is already written and answered by the time this runs.
|
||||
test('emit does not throw when a listener throws synchronously', async () => {
|
||||
const off = on('files:uploaded', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(() => emit('files:uploaded', { paths: [], email: 'x@y' })).not.toThrow();
|
||||
await settle();
|
||||
off();
|
||||
});
|
||||
|
||||
test('emit does not throw when a listener rejects', async () => {
|
||||
const off = on('files:uploaded', async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(() => emit('files:uploaded', { paths: [], email: 'x@y' })).not.toThrow();
|
||||
await settle();
|
||||
off();
|
||||
});
|
||||
|
||||
// One bad subscriber must not stop the others — they are independent, not a chain.
|
||||
test('a failing listener does not stop the next one', async () => {
|
||||
let reached = false;
|
||||
const offBad = on('archive:extracted', () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
const offGood = on('archive:extracted', () => void (reached = true));
|
||||
emit('archive:extracted', { paths: [], email: 'x@y' });
|
||||
await settle();
|
||||
expect(reached).toBe(true);
|
||||
offBad();
|
||||
offGood();
|
||||
});
|
||||
|
||||
test('emit returns immediately rather than awaiting listeners', () => {
|
||||
const off = on('files:placed', async () => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
});
|
||||
const started = performance.now();
|
||||
emit('files:placed', { paths: [], email: 'x@y' });
|
||||
expect(performance.now() - started).toBeLessThan(50);
|
||||
off();
|
||||
});
|
||||
|
||||
test('an event with no listeners is a no-op', () => {
|
||||
expect(listenerCount('files:placed')).toBe(0);
|
||||
expect(() => emit('files:placed', { paths: [], email: 'x@y' })).not.toThrow();
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Platform events, and who is told about them.
|
||||
*
|
||||
* ── A NAMED LIST, not an event bus ──
|
||||
*
|
||||
* The type below is closed on purpose. An open bus — `emit(anyString, anyPayload)` — becomes an API
|
||||
* nobody can change: the moment a plugin listens for `file:thing`, that string and the shape beside it
|
||||
* are a contract, and there is no list anywhere saying so. A closed union means adding an event is a
|
||||
* deliberate edit here, and removing one fails to compile rather than failing quietly at runtime.
|
||||
*
|
||||
* ── Every payload is absolute paths and an owner ──
|
||||
*
|
||||
* Not a file's contents, not a request, not a user object. A subscriber that needs more should read it
|
||||
* from the path, because by the time this fires the work is done and the disk is the truth. Passing
|
||||
* more would be inventing a second description of something already on the filesystem.
|
||||
*
|
||||
* ── Emitting NEVER blocks and NEVER throws ──
|
||||
*
|
||||
* This is fired after a response is decided, and the response must not wait for it. An upload that
|
||||
* takes five extra seconds because a subscriber is thumbnailing a HEIC is an upload somebody reports as
|
||||
* broken; a failing subscriber must not turn a completed upload into an error the caller sees.
|
||||
*
|
||||
* So `emit` returns immediately and swallows everything. That is deliberate rather than lazy: a
|
||||
* subscriber's failure is ITS failure, logged with its name, and the operation that triggered it has
|
||||
* already succeeded.
|
||||
*/
|
||||
|
||||
/** Absolute paths that now exist, and whose account they belong to. */
|
||||
export type FileEventPayload = {
|
||||
/** Absolute. Files, not directories — a subscriber wanting the folder can take the dirname. */
|
||||
paths: string[];
|
||||
/** The account the operation was performed for. */
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type PlatformEvents = {
|
||||
/** One or more files arrived through `POST /file-browser/upload`. */
|
||||
'files:uploaded': FileEventPayload;
|
||||
/** An archive was unpacked. `paths` is what came out of it. */
|
||||
'archive:extracted': FileEventPayload;
|
||||
/** Files were copied or moved to a new location. `paths` is where they are NOW. */
|
||||
'files:placed': FileEventPayload;
|
||||
};
|
||||
|
||||
export type EventName = keyof PlatformEvents;
|
||||
|
||||
type Listener<K extends EventName> = (payload: PlatformEvents[K]) => void | Promise<void>;
|
||||
|
||||
const listeners = new Map<EventName, Set<Listener<EventName>>>();
|
||||
|
||||
/**
|
||||
* Subscribe. Returns the unsubscribe, which nothing currently calls — core's own subscribers live for
|
||||
* the process — but returning it means a future one can be torn down without changing this.
|
||||
*/
|
||||
export function on<K extends EventName>(event: K, listener: Listener<K>): () => void {
|
||||
const set = listeners.get(event) ?? new Set();
|
||||
set.add(listener as Listener<EventName>);
|
||||
listeners.set(event, set);
|
||||
return () => set.delete(listener as Listener<EventName>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell everyone, and get out of the way.
|
||||
*
|
||||
* Synchronous return, work scheduled after. `queueMicrotask` rather than awaiting: the caller is a
|
||||
* request handler about to write a response, and the point is that it does not wait.
|
||||
*/
|
||||
export function emit<K extends EventName>(event: K, payload: PlatformEvents[K]): void {
|
||||
const set = listeners.get(event);
|
||||
if (!set?.size) return;
|
||||
|
||||
for (const listener of set) {
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
// A listener returning a rejected promise is the common case — most of them are async — and it
|
||||
// needs catching separately from a synchronous throw.
|
||||
void Promise.resolve(listener(payload)).catch((err: unknown) => {
|
||||
console.error(`[events] ${event} listener failed —`, err instanceof Error ? err.message : err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[events] ${event} listener threw —`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** For tests: how many listeners an event has. Nothing in the server should need this. */
|
||||
export const listenerCount = (event: EventName): number => listeners.get(event)?.size ?? 0;
|
||||
@@ -0,0 +1,76 @@
|
||||
import { snapshotPlugins } from '../plugins/mount';
|
||||
import { sidecarPort } from '../sidecar-registry';
|
||||
import { on, type EventName, type FileEventPayload } from './bus';
|
||||
|
||||
/**
|
||||
* Letting plugins subscribe to platform events.
|
||||
*
|
||||
* ── The same shape as `fileTypes`, deliberately ──
|
||||
*
|
||||
* A manifest declares `hooks: { 'files:uploaded': '/v1/on-upload' }`. Core resolves that at emit time
|
||||
* from whatever is installed and enabled, POSTs the payload to the plugin's own sidecar, and knows
|
||||
* nothing about which plugin it is. Disable the plugin and the hook stops firing, with nothing to
|
||||
* unregister — the same property that makes a claimed file type disappear cleanly.
|
||||
*
|
||||
* ── Resolved at EMIT time, not at boot ──
|
||||
*
|
||||
* The obvious design registers every plugin's hooks once at startup. It is wrong here: plugins are
|
||||
* installed, updated, enabled and disabled while the server runs, and a registration made at boot
|
||||
* describes a machine that has since changed. Reading the current set costs one database call on an
|
||||
* event that has just done filesystem work, which is not the expensive part of anything.
|
||||
*
|
||||
* ── A hook cannot fail the operation that triggered it ──
|
||||
*
|
||||
* `emit` already swallows, and this adds nothing that could throw past it. The upload succeeded before
|
||||
* anybody was told. A plugin that is down, slow, or returns 500 gets a line in the log and no more —
|
||||
* because the alternative is an upload that reports failure for work that has nothing to do with it.
|
||||
*/
|
||||
|
||||
/** Long enough for real work, short enough that a wedged plugin does not hold a socket all day. */
|
||||
const HOOK_TIMEOUT_MS = 120_000;
|
||||
|
||||
async function dispatch(event: EventName, payload: FileEventPayload): Promise<void> {
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
try {
|
||||
({ states } = await snapshotPlugins());
|
||||
} catch {
|
||||
return; // a database that cannot be read is a louder problem elsewhere
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
|
||||
const path = state.plugin.manifest?.hooks?.[event];
|
||||
if (!path) continue;
|
||||
|
||||
const port = sidecarPort(state.plugin.appName);
|
||||
// Declared but not running — restarting, or never started. Skipped silently: a plugin being down is
|
||||
// not an error of the upload that happened to coincide with it.
|
||||
if (!port) continue;
|
||||
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'x-officer-user': payload.email },
|
||||
body: JSON.stringify({ event, ...payload }),
|
||||
signal: AbortSignal.timeout(HOOK_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[hooks] ${state.plugin.appName} ${event} -> ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[hooks] ${state.plugin.appName} ${event} failed —`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire plugin dispatch into every event. Called once, from the server's own start-up.
|
||||
*
|
||||
* One listener per event rather than one per plugin: which plugins care is resolved when the event
|
||||
* fires, so there is nothing to add or remove as they come and go.
|
||||
*/
|
||||
export function registerPluginHooks(): void {
|
||||
const events: EventName[] = ['files:uploaded', 'archive:extracted', 'files:placed'];
|
||||
for (const event of events) on(event, (payload) => dispatch(event, payload));
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../data-path';
|
||||
import { buildThumbnail, thumbnailKind } from '../api/file-browser/thumbnails';
|
||||
import { pluginFileTypes, sidecarFetch } from '../api/file-browser/plugin-file-types';
|
||||
import { on, type FileEventPayload } from './bus';
|
||||
|
||||
/**
|
||||
* Make the thumbnails before anybody asks for them.
|
||||
*
|
||||
* ── Why this is worth doing at all ──
|
||||
*
|
||||
* Thumbnails are generated on demand and cached. That is right — most files are never looked at — but
|
||||
* it means the first person to open a folder waits for the work. For a jpeg that is 40ms nobody
|
||||
* notices. For a HEIC it is four and a half seconds each, because ImageMagick decodes the whole
|
||||
* 4032x3024 frame before sharp sees it. A folder of photographs from a phone is thirteen seconds of
|
||||
* staring at file icons.
|
||||
*
|
||||
* An upload is the one moment the platform KNOWS which files are new, so it is the moment to do it.
|
||||
*
|
||||
* ── This is core subscribing to core, and that is the point ──
|
||||
*
|
||||
* The thumbnail cache belongs to the platform: keyed on its scheme, read by its route. A plugin doing
|
||||
* this work would produce them somewhere the file browser never looks. So the warmer lives here and
|
||||
* uses the same `buildThumbnail` the request path uses — which means a HEIC still warms through
|
||||
* av-club, via the `fileTypes` claim, without this file knowing that plugin exists.
|
||||
*
|
||||
* ── ONE size, and it is the one both views ask for ──
|
||||
*
|
||||
* The cache key includes the size. Warming 128 when the browser asks for 256 is work that produces
|
||||
* nothing anybody reads, so this must stay equal to what `FileItem` requests. Both views were made to
|
||||
* ask for 256 for exactly this reason.
|
||||
*/
|
||||
|
||||
const WARM_SIZE = 256;
|
||||
|
||||
/**
|
||||
* How many to warm at once.
|
||||
*
|
||||
* Lower than the request path's own limit on purpose. This is background work competing with a person
|
||||
* who is actually waiting: somebody opening a different folder while an upload warms must not queue
|
||||
* behind it. `buildThumbnail` has its own cap too, so this is a second, tighter gate rather than the
|
||||
* only one.
|
||||
*/
|
||||
const WARM_CONCURRENCY = 2;
|
||||
|
||||
/** Stop after this many. An unpacked archive can hold thousands, and warming all of them is not a favour. */
|
||||
const WARM_LIMIT = 200;
|
||||
|
||||
async function warm(payload: FileEventPayload): Promise<void> {
|
||||
const claimed = await pluginFileTypes();
|
||||
|
||||
// Only what will actually be asked for: core's own types plus whatever a plugin claims. Anything else
|
||||
// has no thumbnail to make and would be a spawn per file for nothing.
|
||||
const targets = payload.paths
|
||||
.filter((path) => {
|
||||
const name = path.slice(path.lastIndexOf('/') + 1);
|
||||
return !!thumbnailKind(name) || claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
|
||||
})
|
||||
.slice(0, WARM_LIMIT);
|
||||
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const userDataDir = join(DATA_PATH, payload.email);
|
||||
let index = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (index < targets.length) {
|
||||
const path = targets[index++]!;
|
||||
const name = path.slice(path.lastIndexOf('/') + 1);
|
||||
const kind = thumbnailKind(name);
|
||||
const claim = kind ? null : claimed.get(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
|
||||
|
||||
/*
|
||||
Exactly what the request path does, including its cache and its remembered failures — so a file
|
||||
warmed here is a cache HIT when the browser asks, and a file that cannot be thumbnailed is not
|
||||
attempted twice.
|
||||
*/
|
||||
await buildThumbnail(
|
||||
path,
|
||||
userDataDir,
|
||||
kind ?? 'image',
|
||||
WARM_SIZE,
|
||||
claim?.thumbnail
|
||||
? async () => {
|
||||
const res = await sidecarFetch(
|
||||
`${claim.thumbnail}?path=${encodeURIComponent(path)}&size=${WARM_SIZE}`,
|
||||
payload.email,
|
||||
);
|
||||
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
|
||||
}
|
||||
: undefined,
|
||||
).catch(() => {
|
||||
// Already logged by the builder where it matters. A warm that fails costs nothing: the request
|
||||
// path will try again and answer honestly.
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, targets.length) }, worker));
|
||||
}
|
||||
|
||||
/** Called once from start-up. */
|
||||
export function registerThumbnailWarmer(): void {
|
||||
on('files:uploaded', warm);
|
||||
on('archive:extracted', warm);
|
||||
on('files:placed', warm);
|
||||
}
|
||||
@@ -18,9 +18,14 @@ import { catalogueEntry, listCatalogue } from '../../databases/marketplace_db/sr
|
||||
*
|
||||
* ── Read-only, and structurally so ──
|
||||
*
|
||||
* There are two GET routes and no others. Registering a plugin is `scripts/marketplace-sync.ts`, run by
|
||||
* hand against the database. That is not a temporary shortcut standing in for a write API: while
|
||||
* submissions do not exist, a public write surface is all risk and no feature.
|
||||
* There are two GET routes and no others. Everything that WRITES the catalogue — registering a plugin,
|
||||
* publishing a release — is `scripts/marketplace-sync.ts` against the database, on the one machine that
|
||||
* publishes. That is not a temporary shortcut standing in for a write API: while submissions do not
|
||||
* exist, a public write surface is all risk and no feature.
|
||||
*
|
||||
* It stayed that way when publishing went onto a timer, which is the moment it would have been easy to
|
||||
* add a `POST /api/sync` for a Gitea webhook. A minute of delay is cheaper than a write route on the one
|
||||
* process here that has no login.
|
||||
*
|
||||
* ── CORS is wide open, deliberately ──
|
||||
*
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
import { existsSync, mkdirSync } from 'node:fs';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
listRegistry,
|
||||
type RegistryRow,
|
||||
upsertMetadata,
|
||||
upsertPlugin,
|
||||
} from '../../databases/marketplace_db/src/plugins/queries';
|
||||
|
||||
/**
|
||||
* Fill the catalogue from what each plugin has RELEASED.
|
||||
*
|
||||
* ── The bug this file exists to remove ──
|
||||
*
|
||||
* Until 2026-09-03 the sync read `platform/plugins/` — the directories this machine has INSTALLED — and
|
||||
* imported each `manifest.ts` off the working tree. So "publish" meant "make sure officer-dev's checkout
|
||||
* happens to be at the release, then run the script", and nobody could see when it was not. On the day
|
||||
* this was rewritten the live catalogue was advertising four plugins from untagged working copies:
|
||||
* av-club 2026.08.29.01, kanban 2026.08.29.02, openseo 0.1.0 and fofos-admin 2026.08.29.26, none of
|
||||
* which had ever been tagged. The store was describing somebody's afternoon.
|
||||
*
|
||||
* That is also why the old script could not be put on a timer: a cron over it would have published
|
||||
* whatever was sitting in a working tree, on a schedule.
|
||||
*
|
||||
* ── What replaces it: a bare mirror, read at a tag ──
|
||||
*
|
||||
* Every plugin in the registry is mirrored (`git clone --mirror`) into a cache directory, refreshed, and
|
||||
* read AT ITS NEWEST TAG — the manifest, the README, the file list, all out of `git cat-file`. There is
|
||||
* no working tree anywhere in this path, so publishing something uncommitted is not a mistake that can
|
||||
* be made. A mirror is cheap: the whole set clones in a couple of seconds and refreshes in well under
|
||||
* one.
|
||||
*
|
||||
* `docs/marketplace.md` proposed instead reading manifests over Gitea's API, which needed either a
|
||||
* sandbox to evaluate a fetched string or a committed `manifest.json` for every plugin. Git gives the
|
||||
* same guarantee — content at a named release, independent of any checkout — with neither, and the trust
|
||||
* model is unchanged from today: we import our own repositories' code.
|
||||
*
|
||||
* `[still a blocker for third-party submissions]` It is unchanged BECAUSE it is unchanged. Importing a
|
||||
* stranger's manifest is code execution no matter which ref it came from. Submissions still need a
|
||||
* sandbox or a published `manifest.json`.
|
||||
*
|
||||
* ── Which plugins, and where from: the registry, and only the registry ──
|
||||
*
|
||||
* The list comes from the `plugins` table, which `docs/marketplace.md` has always called the
|
||||
* authoritative, hand-curated half. It was not actually being used that way — the old sync discovered
|
||||
* plugins by listing a directory, so the registry was a by-product of whatever officer-dev had
|
||||
* installed. Now it decides, which means:
|
||||
*
|
||||
* · a plugin nobody has installed here can still be published (`finances` is in exactly that state),
|
||||
* · a plugin lives wherever its registry row says, across orgs — `fofos-admin` is under `fofos/`,
|
||||
* not `plugins/`, so an org listing would have missed it,
|
||||
* · and adding one is a deliberate act: `marketplace-sync.ts --add <https clone url>`.
|
||||
*
|
||||
* ── A plugin with no tag is left ALONE, not removed ──
|
||||
*
|
||||
* It has never made a release, so this file has nothing true to say about it and says nothing: the
|
||||
* existing metadata row stays, and the run reports it. Deleting the row would drop it out of the store
|
||||
* on the day the rule changed, which is a decision about somebody's plugin dressed up as a refresh.
|
||||
* Tagging it is the fix, and the report is there to ask for one.
|
||||
*/
|
||||
|
||||
export type SyncOutcome =
|
||||
| { appName: string; status: 'synced'; tag: string; version: string }
|
||||
/** The release on the remote is the one already published. Nothing was written. */
|
||||
| { appName: string; status: 'unchanged'; tag: string }
|
||||
| { appName: string; status: 'unreleased' }
|
||||
| { appName: string; status: 'failed'; error: string };
|
||||
|
||||
/*
|
||||
Where the mirrors live.
|
||||
|
||||
A fixed path under the user's cache directory, NOT one derived from the environment. This runs three
|
||||
ways — by hand with `--env-file=.env`, from the webhook inside `officer-marketplace`, and from cron —
|
||||
and those three do not have the same environment. A path that moved with `DATA_PATH` would have each
|
||||
of them re-cloning twenty-five repositories into its own copy, and the "is it already mirrored" check
|
||||
would answer differently depending on who asked.
|
||||
|
||||
Not in the source tree, because it is a cache of remote repositories and a `.gitignore` entry is a
|
||||
worse way to keep several hundred megabytes out of a repo than not putting them there.
|
||||
*/
|
||||
const MIRROR_DIR =
|
||||
process.env.MARKETPLACE_MIRROR_DIR ?? join(homedir(), '.cache', 'officer-marketplace', 'mirrors');
|
||||
|
||||
const GIT_TIMEOUT_MS = 60_000;
|
||||
|
||||
type Git = { ok: true; out: string } | { ok: false; err: string };
|
||||
|
||||
async function git(args: string[], cwd?: string): Promise<Git> {
|
||||
// A missing `cwd` makes posix_spawn fail with ENOENT, which reads as "git is not installed" and sent
|
||||
// a first run of this looking for git for ten minutes. It means the mirror is not there yet.
|
||||
if (cwd && !existsSync(cwd)) return { ok: false, err: `${cwd} does not exist` };
|
||||
|
||||
const proc = Bun.spawn(['git', ...args], {
|
||||
cwd,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
// Never inherited: a mirror fetch that hits a host asking for a password would otherwise sit on a
|
||||
// terminal prompt forever, and in the webhook server there is no terminal to sit on.
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '/bin/true' },
|
||||
signal: AbortSignal.timeout(GIT_TIMEOUT_MS),
|
||||
});
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
return (await proc.exited) === 0 ? { ok: true, out: out.trim() } : { ok: false, err: err.trim() || 'git failed' };
|
||||
}
|
||||
|
||||
/** The mirror for a plugin, cloned if this is the first time and refreshed if it is not. */
|
||||
async function mirror(appName: string, repoUrl: string): Promise<string> {
|
||||
mkdirSync(MIRROR_DIR, { recursive: true });
|
||||
const dir = join(MIRROR_DIR, `${appName}.git`);
|
||||
|
||||
// Re-cloned rather than repaired when it is not a healthy bare repo: an interrupted clone leaves a
|
||||
// directory that every later command fails against, and there is nothing in it worth keeping.
|
||||
const existing = await git(['rev-parse', '--is-bare-repository'], dir);
|
||||
if (!existing.ok || existing.out !== 'true') {
|
||||
if (existsSync(dir)) await Bun.$`rm -rf ${dir}`.quiet().nothrow();
|
||||
const cloned = await git(['clone', '--mirror', '--quiet', repoUrl, dir]);
|
||||
if (!cloned.ok) throw new Error(`clone failed: ${cloned.err}`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
// The registry is allowed to move a plugin. Setting the URL every time is one command and removes the
|
||||
// failure where a repo is relocated and the mirror keeps refreshing from where it used to be.
|
||||
await git(['remote', 'set-url', 'origin', repoUrl], dir);
|
||||
|
||||
// `--prune --prune-tags`: a deleted or MOVED tag has to disappear here too. Without it, a tag that was
|
||||
// pushed by mistake and deleted would stay in the mirror forever and could go on being published as
|
||||
// the newest release — a store advertising a version its repository no longer has.
|
||||
const fetched = await git(['fetch', '--quiet', '--prune', '--prune-tags', '--tags', 'origin'], dir);
|
||||
if (!fetched.ok) throw new Error(`fetch failed: ${fetched.err}`);
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest release, or null for a plugin that has never made one.
|
||||
*
|
||||
* Asked of git rather than by sorting version strings, which is the same rule `servers/plugins/release.ts`
|
||||
* follows and for the same reason: parsing `2026.08.27.01` works right up until somebody tags `v2`, and
|
||||
* then fails silently. `--abbrev=0` gives the closest tag reachable from HEAD, and a mirror's HEAD is the
|
||||
* remote's default branch, so this is "the newest release on the branch the repository points at".
|
||||
*/
|
||||
async function newestTag(dir: string): Promise<string | null> {
|
||||
const described = await git(['describe', '--tags', '--abbrev=0', 'HEAD'], dir);
|
||||
return described.ok ? described.out : null;
|
||||
}
|
||||
|
||||
/** A file at a ref, or null when it is not in that tree. */
|
||||
async function fileAt(dir: string, ref: string, path: string): Promise<string | null> {
|
||||
const shown = await git(['cat-file', '-p', `${ref}:${path}`], dir);
|
||||
return shown.ok ? shown.out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The manifest at a release, as an object.
|
||||
*
|
||||
* Written to a temp file and imported, because a manifest is TypeScript. Keyed by the COMMIT, so the
|
||||
* module cache is correct rather than merely convenient: the same commit is the same file, and a tag
|
||||
* moved to a different commit gets a different path and a fresh import. That matters in the webhook
|
||||
* server, which is long-lived and would otherwise serve the first version of a manifest it ever read.
|
||||
*
|
||||
* The `import type { PluginManifest }` at the top of every manifest is erased by the transpiler, so the
|
||||
* file has no runtime dependency on the platform's path aliases and imports fine from anywhere.
|
||||
*/
|
||||
async function manifestAt(dir: string, ref: string, appName: string, commit: string): Promise<Record<string, unknown>> {
|
||||
const source = await fileAt(dir, ref, 'manifest.ts');
|
||||
if (source === null) throw new Error(`no manifest.ts at ${ref}`);
|
||||
|
||||
const scratch = join(tmpdir(), 'officer-marketplace-manifests');
|
||||
mkdirSync(scratch, { recursive: true });
|
||||
const path = join(scratch, `${appName}-${commit}.ts`);
|
||||
await Bun.write(path, source);
|
||||
|
||||
const mod = (await import(path)) as { manifest?: Record<string, unknown> };
|
||||
if (!mod.manifest) throw new Error(`manifest.ts at ${ref} exports no \`manifest\``);
|
||||
return mod.manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* A raw-file URL for a Gitea repository, PINNED TO THE RELEASE.
|
||||
*
|
||||
* It used to be `/raw/HEAD/…`, which works only because Gitea 303s it to `/raw/branch/HEAD/…`; the
|
||||
* artwork it served was whatever was on the default branch, so a plugin could show one icon in the store
|
||||
* and ship another. `/raw/tag/<tag>/…` answers 200 directly and shows the icon that belongs to the
|
||||
* version being advertised.
|
||||
*
|
||||
* `[open]` Still Gitea-shaped. A GitHub-hosted plugin needs the other form, and that wants the forge to
|
||||
* be part of the registry rather than inferred from the URL.
|
||||
*/
|
||||
function rawUrl(repoUrl: string, tag: string, path: string): string {
|
||||
return `${repoUrl.replace(/\.git$/, '')}/raw/tag/${encodeURIComponent(tag)}/${path}`;
|
||||
}
|
||||
|
||||
/*
|
||||
`force` re-publishes a release that is already published.
|
||||
|
||||
Needed because "unchanged" is judged on the plugin's commit, and that is only half the input: the other
|
||||
half is THIS file. Change how a field is derived — as the switch to tag-pinned icon URLs did — and every
|
||||
row is stale while every commit still matches. Then the run to fix them all is `--force`.
|
||||
*/
|
||||
async function syncRegistered(row: RegistryRow, force: boolean): Promise<SyncOutcome> {
|
||||
const { appName, repoUrl } = row;
|
||||
const dir = await mirror(appName, repoUrl);
|
||||
|
||||
const tag = await newestTag(dir);
|
||||
if (!tag) return { appName, status: 'unreleased' };
|
||||
|
||||
const commitAt = await git(['rev-list', '-n', '1', tag], dir);
|
||||
if (!commitAt.ok) throw new Error(`cannot resolve ${tag}`);
|
||||
const commit = commitAt.out;
|
||||
|
||||
// Before the manifest is read, not after: this is what makes a per-minute run affordable. Twenty-five
|
||||
// repositories with nothing new cost one query, twenty-five fetches of nothing, and no writes at all.
|
||||
if (!force && row.sourceCommit === commit) return { appName, status: 'unchanged', tag };
|
||||
|
||||
const manifest = await manifestAt(dir, tag, appName, commit);
|
||||
|
||||
// What the plugin SHIPS, read from the tree at the release rather than from a declaration — the same
|
||||
// rule discovery follows on disk, so a catalogue entry cannot claim a sidecar the release does not
|
||||
// have.
|
||||
const listed = await git(['ls-tree', '-r', '--name-only', tag], dir);
|
||||
const files = new Set(listed.ok ? listed.out.split('\n') : []);
|
||||
const has = {
|
||||
api: files.has('api/router.ts'),
|
||||
schema: files.has('db/schema.ts'),
|
||||
sidecar: files.has('sidecar/index.ts') || files.has('sidecar/index.mjs'),
|
||||
web: [...files].some((f) => f.startsWith('web/')),
|
||||
};
|
||||
|
||||
const readme = (await fileAt(dir, tag, 'README.md')) ?? (await fileAt(dir, tag, 'readme.md'));
|
||||
|
||||
const version = String(manifest.version ?? tag);
|
||||
if (version !== tag) {
|
||||
// Not an error and not corrected here. The tag is what was released and the manifest is what the
|
||||
// plugin will report about itself once installed; when they disagree, an update badge that compares
|
||||
// versions goes quiet. Saying so is the only way anyone finds out.
|
||||
console.warn(`[marketplace-sync] ${appName}: tag ${tag} but manifest says ${version} — they should match`);
|
||||
}
|
||||
|
||||
const developer = String(manifest.publisher ?? 'unknown');
|
||||
const pluginId = await upsertPlugin({ appName, developer, repoUrl });
|
||||
|
||||
await upsertMetadata(pluginId, {
|
||||
version,
|
||||
releaseTag: tag,
|
||||
platform: String(manifest.platform ?? '>=1.0.0'),
|
||||
label: String(manifest.label ?? appName),
|
||||
summary: String(manifest.summary ?? ''),
|
||||
// Taken verbatim, NOT validated here. `manifestProblems` already refuses a tag outside the
|
||||
// vocabulary at discovery, so anything reaching this point is either valid or belongs to a plugin
|
||||
// the platform would not load anyway — and a sync that silently dropped a tag would be a catalogue
|
||||
// quietly disagreeing with the repository it claims to mirror.
|
||||
tags: Array.isArray(manifest.tags) ? manifest.tags : [],
|
||||
color: (manifest.color as string) ?? null,
|
||||
tile: (manifest.tile as string) ?? null,
|
||||
iconUrl: files.has('assets/icon.png') ? rawUrl(repoUrl, tag, 'assets/icon.png') : null,
|
||||
icon: (manifest.icon as string) ?? null,
|
||||
permissions: manifest.permissions ?? [],
|
||||
osDependencies: manifest.osDependencies ?? null,
|
||||
has,
|
||||
readme,
|
||||
sourceCommit: commit,
|
||||
});
|
||||
|
||||
return { appName, status: 'synced', tag, version };
|
||||
}
|
||||
|
||||
/** One plugin by app name. Unknown to the registry is a failure, not a silent no-op. */
|
||||
export async function syncPlugin(appName: string, force = false): Promise<SyncOutcome> {
|
||||
const row = (await listRegistry()).find((r) => r.appName === appName);
|
||||
if (!row) return { appName, status: 'failed', error: 'not in the registry' };
|
||||
return syncRegistered(row, force).catch((err) => ({
|
||||
appName,
|
||||
status: 'failed' as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every registered plugin, one at a time.
|
||||
*
|
||||
* Sequential on purpose. Twenty-four concurrent fetches against one Gitea is a thundering herd for a job
|
||||
* nothing is waiting on, and the whole set finishes in about a second anyway.
|
||||
*
|
||||
* Unlisted rows are included: `listed` hides a plugin from the public API, usually after a bad release,
|
||||
* and it has to be able to come back with current metadata rather than with whatever was true when it
|
||||
* was pulled.
|
||||
*/
|
||||
export async function syncAll(force = false): Promise<SyncOutcome[]> {
|
||||
const rows = await listRegistry();
|
||||
const out: SyncOutcome[] = [];
|
||||
for (const row of rows) {
|
||||
out.push(
|
||||
await syncRegistered(row, force).catch((err) => ({
|
||||
appName: row.appName,
|
||||
status: 'failed' as const,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -98,6 +98,8 @@ function findWeb(
|
||||
screens: [
|
||||
{ segment: null, label: manifest?.label ?? appName, icon: manifest?.icon, art: null, panels, layout },
|
||||
],
|
||||
// Optional and unannounced — presence is the declaration, as it is for panels and layout.
|
||||
fileActions: fileOrNull(join(webDir, 'file-actions.ts')),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -157,7 +159,7 @@ function findWeb(
|
||||
}
|
||||
|
||||
if (problems.length) return { web: null, error: `${appName}: ${problems.join('; ')}.` };
|
||||
return { web: { screens } };
|
||||
return { web: { screens, fileActions: fileOrNull(join(webDir, 'file-actions.ts')) } };
|
||||
}
|
||||
|
||||
const fileOrNull = (path: string): string | null => (existsSync(path) ? path : null);
|
||||
|
||||
@@ -30,8 +30,22 @@ import type { DiscoveredPlugin } from './manifest';
|
||||
|
||||
const ECOSYSTEM_PATH = join(PLATFORM_DIR, 'ecosystem.config.cjs');
|
||||
|
||||
/** One PM2 app entry. Matches what `services.sh` emits for the core processes. */
|
||||
export type Pm2App = { name: string; script: string; args?: string; cwd: string; watch: boolean };
|
||||
/**
|
||||
* One PM2 app entry. Matches what `services.sh` emits for the core processes.
|
||||
*
|
||||
* `env` carries the ports the platform allocated for this plugin — `PORT_<NAME>`. It is written into the
|
||||
* ecosystem file rather than sent over the registration socket because a socket answer arrives after the
|
||||
* process has already had to decide what to bind, and pm2 is the only thing that can tell a process
|
||||
* something before it starts.
|
||||
*/
|
||||
export type Pm2App = {
|
||||
name: string;
|
||||
script: string;
|
||||
args?: string;
|
||||
cwd: string;
|
||||
watch: boolean;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header to write when there is no file to take one from.
|
||||
@@ -81,12 +95,24 @@ function writeEcosystemApps(apps: Pm2App[]): void {
|
||||
const marker = existing.indexOf('module.exports');
|
||||
const header = marker > 0 ? existing.slice(0, marker) : FALLBACK_HEADER;
|
||||
|
||||
const lines = apps.map(
|
||||
(app) =>
|
||||
const lines = apps.map((app) => {
|
||||
/*
|
||||
Values are emitted as JSON rather than quoted by hand. A port is a number today, but this is the
|
||||
seam where a path or a URL arrives eventually, and one apostrophe in a value would produce a file
|
||||
PM2 cannot parse — which takes every process on the machine down, not just this plugin's.
|
||||
*/
|
||||
const env =
|
||||
app.env && Object.keys(app.env).length
|
||||
? `, env: { ${Object.entries(app.env)
|
||||
.map(([k, v]) => `${JSON.stringify(k)}: ${JSON.stringify(v)}`)
|
||||
.join(', ')} }`
|
||||
: '';
|
||||
return (
|
||||
` { name: '${app.name}', script: '${app.script}'` +
|
||||
(app.args ? `, args: '${app.args}'` : '') +
|
||||
`, cwd: '${app.cwd}', watch: ${app.watch} },`,
|
||||
);
|
||||
`, cwd: '${app.cwd}', watch: ${app.watch}${env} },`
|
||||
);
|
||||
});
|
||||
writeFileSync(ECOSYSTEM_PATH, `${header}module.exports = {\n apps: [\n${lines.join('\n')}\n ],\n};\n`);
|
||||
}
|
||||
|
||||
@@ -98,7 +124,7 @@ function writeEcosystemApps(apps: Pm2App[]): void {
|
||||
* the database says is installed and enabled. Neither should duplicate the other's idea of what a
|
||||
* plugin's entry looks like.
|
||||
*/
|
||||
export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
|
||||
export function pluginPm2Entry(plugin: DiscoveredPlugin, ports?: Record<string, number>): Pm2App | null {
|
||||
if (!plugin.sidecar) return null;
|
||||
|
||||
const name = pluginProcessName(plugin.appName);
|
||||
@@ -108,9 +134,16 @@ export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
|
||||
? plugin.sidecar.script.slice(PLATFORM_DIR.length + 1)
|
||||
: plugin.sidecar.script;
|
||||
|
||||
// `PORT_<NAME>`, uppercased. Absent entirely when a plugin asked for none, which is nearly all of them.
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(ports ?? {}).map(([name, port]) => [`PORT_${name.toUpperCase().replace(/-/g, '_')}`, String(port)]),
|
||||
);
|
||||
|
||||
const base = { name, cwd: PLATFORM_DIR, watch: false, ...(Object.keys(env).length ? { env } : {}) };
|
||||
|
||||
return plugin.sidecar.runtime === 'node'
|
||||
? { name, script: 'node', args: script, cwd: PLATFORM_DIR, watch: false }
|
||||
: { name, script: 'bun', args: `run ${script}`, cwd: PLATFORM_DIR, watch: false };
|
||||
? { ...base, script: 'node', args: script }
|
||||
: { ...base, script: 'bun', args: `run ${script}` };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,8 +152,8 @@ export function pluginPm2Entry(plugin: DiscoveredPlugin): Pm2App | null {
|
||||
* Idempotent, because re-installing is how a plugin is upgraded and the script path may have moved.
|
||||
* Returns false when the plugin has no sidecar at all, which is not a failure — most plugins won't.
|
||||
*/
|
||||
export function addPluginToEcosystem(plugin: DiscoveredPlugin): boolean {
|
||||
const entry = pluginPm2Entry(plugin);
|
||||
export function addPluginToEcosystem(plugin: DiscoveredPlugin, ports?: Record<string, number>): boolean {
|
||||
const entry = pluginPm2Entry(plugin, ports);
|
||||
if (!entry) return false;
|
||||
|
||||
const apps = readEcosystemApps().filter((a) => a.name !== entry.name);
|
||||
|
||||
@@ -66,6 +66,24 @@ export function generatePluginsModule(states: PluginState[]): string {
|
||||
// it always did; caldav has two, so it emits two routes, two layout keys and two dock tiles from one
|
||||
// install. The alias counter runs over screens for that reason — two screens of one plugin must not
|
||||
// share an import name.
|
||||
/*
|
||||
File-browser actions, one block per PLUGIN rather than per screen.
|
||||
|
||||
The context menu is global — it is the same menu wherever the browser is mounted — so an action
|
||||
belongs to the plugin, not to one of its screens. A plugin with two screens contributes its actions
|
||||
once.
|
||||
*/
|
||||
const actionImports: string[] = [];
|
||||
const actionEntries: string[] = [];
|
||||
let a = 0;
|
||||
for (const { plugin } of withWeb) {
|
||||
if (!plugin.web?.fileActions) continue;
|
||||
const alias = `Actions${a}`;
|
||||
a += 1;
|
||||
actionImports.push(`import { fileActions as ${alias} } from '${specifier(plugin.web.fileActions)}';`);
|
||||
actionEntries.push(` ...${alias},`);
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
for (const { plugin } of withWeb) {
|
||||
for (const screen of plugin.web!.screens) {
|
||||
@@ -85,8 +103,19 @@ export function generatePluginsModule(states: PluginState[]): string {
|
||||
}
|
||||
|
||||
const body = `${HEADER}
|
||||
import type { AppRegistryMeta, LayoutNode } from 'officerdev';
|
||||
import type { AppRegistryMeta, FileAction, LayoutNode } from 'officerdev';
|
||||
${imports.join('\n')}
|
||||
${actionImports.join('\n')}
|
||||
|
||||
/**
|
||||
* Context-menu actions plugins contribute to the file browser.
|
||||
*
|
||||
* Flat and global: the menu is the same wherever the browser is mounted, so these are not scoped to a
|
||||
* screen. Each decides for itself whether it applies to what was selected.
|
||||
*/
|
||||
export const pluginFileActions: FileAction[] = [
|
||||
${actionEntries.join('\n')}
|
||||
];
|
||||
|
||||
export type GeneratedPlugin = {
|
||||
/** The plugin this screen belongs to. Two screens of one plugin share it. */
|
||||
@@ -183,6 +212,27 @@ export async function rebuildFrontend(): Promise<BuildResult> {
|
||||
plugins: [tailwind],
|
||||
minify: process.env.NODE_ENV === 'production',
|
||||
sourcemap: 'none',
|
||||
/*
|
||||
WITHOUT THIS A PLUGIN CAN TAKE THE WHOLE APP DOWN WITH ONE LINE.
|
||||
|
||||
`scripts/build/web.ts` — the platform's own bundle — defines `process.env.NODE_ENV`. This build
|
||||
defined nothing, so any `process.*` in a plugin's frontend was emitted verbatim into a browser
|
||||
chunk and threw `ReferenceError: process is not defined` at module evaluation. One throwing module
|
||||
fails the whole graph, so the symptom is not a broken panel, it is a blank platform.
|
||||
|
||||
Reached by vendored code rather than by anyone writing Node in a component: m3e-canvas carries four
|
||||
files from a Next.js original that read `process.env.NEXT_PUBLIC_BASE_PATH`. Vendored trees stay
|
||||
close to upstream here (see the hls.js note in CLAUDE.md), so the build is the right place to
|
||||
absorb it — and it protects every plugin rather than the one that happened to hit it.
|
||||
|
||||
Both keys, and the order does not matter: the longest match wins, so `NODE_ENV` keeps its real
|
||||
value and every other `process.env.X` becomes `({}).X`, which is `undefined` — exactly what the
|
||||
`?? ''` in that vendored code expects. Verified against `bun build` rather than assumed.
|
||||
*/
|
||||
define: {
|
||||
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV === 'production' ? 'production' : 'development'),
|
||||
'process.env': '({})',
|
||||
},
|
||||
});
|
||||
if (!result.success) {
|
||||
return {
|
||||
|
||||
@@ -15,7 +15,7 @@ import { deleteProcess, processStatus, startProcess, stopProcess } from '../app-
|
||||
import { addPluginToEcosystem, pluginProcessName, removePluginFromEcosystem } from './ecosystem';
|
||||
import { discoverPlugins } from './discover';
|
||||
import { fetchPlugin } from './fetch';
|
||||
import { revokeGrantsForPermissions } from 'officerdb';
|
||||
import { allocatePluginPort, releasePluginPorts, revokeGrantsForPermissions } from 'officerdb';
|
||||
import { grantPluginToAllRoles } from './grant-on-install';
|
||||
import { publishAssets, unpublishAssets } from '../app-store/assets';
|
||||
import { refreshPluginMounts, snapshotPlugins } from './mount';
|
||||
@@ -453,7 +453,29 @@ export async function installPlugin(
|
||||
);
|
||||
|
||||
if (hasSidecar(plugin)) {
|
||||
addPluginToEcosystem(plugin);
|
||||
/*
|
||||
Ports the manifest asked the platform to own, allocated before the entry is written because the
|
||||
entry carries them.
|
||||
|
||||
Idempotent: a plugin that already has an allocation gets the same number back, so re-installing
|
||||
and upgrading do not move a port somebody has written into an nginx proxy host. See
|
||||
`pluginPorts` in officerdb for why the platform allocates these when it allocates nothing else.
|
||||
*/
|
||||
const ports: Record<string, number> = {};
|
||||
for (const declared of plugin.manifest.extraPorts ?? []) {
|
||||
ports[declared.name] = await allocatePluginPort(appName, declared.name);
|
||||
}
|
||||
if (Object.keys(ports).length) {
|
||||
await step(
|
||||
steps,
|
||||
onStep,
|
||||
`ports: ${Object.entries(ports)
|
||||
.map(([name, port]) => `${name}=${port}`)
|
||||
.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
addPluginToEcosystem(plugin, ports);
|
||||
await step(steps, onStep, `ecosystem: ${pluginProcessName(appName)} added`);
|
||||
|
||||
const started = await startProcess(pluginProcessName(appName), PLATFORM_DIR);
|
||||
@@ -609,6 +631,17 @@ export async function purgePlugin(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Ports go here and NOT on uninstall, which is the deliberate asymmetry.
|
||||
|
||||
An allocated number is written down where this database cannot see it — an nginx proxy host, a
|
||||
firewall rule, somebody's notes. Uninstalling is reversible and often temporary, so giving the number
|
||||
up there would let a later plugin take it and point that configuration at the wrong service. Purge is
|
||||
the act that says the plugin is gone for good, and that is when the number goes back in the pool.
|
||||
*/
|
||||
const released = await releasePluginPorts(appName);
|
||||
if (released) await step(steps, onStep, `ports released: ${released}`);
|
||||
|
||||
await rm(plugin.dir, { recursive: true, force: true });
|
||||
await step(steps, onStep, `deleted ${plugin.dir}`);
|
||||
|
||||
|
||||
@@ -405,6 +405,51 @@ export type PluginManifest = {
|
||||
*/
|
||||
protocolHeaders?: string[];
|
||||
|
||||
/**
|
||||
* Ports this plugin needs the PLATFORM to allocate and remember.
|
||||
*
|
||||
* ── Not the same thing as a sidecar's own port ──
|
||||
*
|
||||
* A sidecar already gets a port, and nothing here changes that: it binds `port: 0`, the kernel picks
|
||||
* one, and it announces the number over the registration socket. That port is a private detail between
|
||||
* two processes — loopback, re-chosen on every restart, and nothing outside Officer ever needs it.
|
||||
*
|
||||
* This is for a port something ELSE connects to. A plugin serving a website has to be reachable from
|
||||
* nginx, which means the number must survive a restart and must be knowable by whoever writes the proxy
|
||||
* configuration. Ephemeral fails both, which is why the first site plugin had to hardcode 3011 and hope.
|
||||
*
|
||||
* ── What the platform guarantees, and what it does not ──
|
||||
*
|
||||
* Guaranteed: the same plugin and name always get the same number back, and no two plugins are ever
|
||||
* given the same one — that second part is a unique index, not a convention, because a collision is two
|
||||
* services fighting over a listener rather than a duplicate row.
|
||||
*
|
||||
* NOT guaranteed: that the port is still free when the sidecar starts. Something else on the machine
|
||||
* can take it while Officer is down. A sidecar that cannot bind its remembered port must fail loudly
|
||||
* rather than quietly choose another, because the number is written down where this platform cannot
|
||||
* see it.
|
||||
*
|
||||
* ── How it reaches the sidecar ──
|
||||
*
|
||||
* As `PORT_<NAME>` in its environment, uppercased, written into the pm2 entry at install. The sidecar
|
||||
* reads it rather than being told over the socket, because a socket answer would arrive after the
|
||||
* process has already had to decide what to bind.
|
||||
*
|
||||
* ```ts
|
||||
* extraPorts: [{ name: 'site' }] // → PORT_SITE=<n> in the sidecar's environment
|
||||
* ```
|
||||
*
|
||||
* A manifest does NOT get to ask for a particular number, and that is deliberate. A preference sounds
|
||||
* helpful and fails silently: when the wished-for port is taken the plugin is handed a different one,
|
||||
* the install succeeds, and whatever was configured against the old number keeps pointing at nothing.
|
||||
* Nobody is told. Drawing a fresh one every time makes the number always something you had to go and
|
||||
* read, which is the state that keeps the configuration honest.
|
||||
*
|
||||
* Binding is still the sidecar's business, including which interface. A port that exists to be reached
|
||||
* from a container cannot be loopback, and the platform has no way to know which a plugin means.
|
||||
*/
|
||||
extraPorts?: { name: string }[];
|
||||
|
||||
/**
|
||||
* Host binaries this plugin needs. Absent means it is self-sufficient, which offscale and example are.
|
||||
*
|
||||
@@ -509,6 +554,45 @@ export type PluginManifest = {
|
||||
render?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Platform events this plugin wants to be told about, and where to tell it.
|
||||
*
|
||||
* `{ 'files:uploaded': '/v1/on-upload' }` — the key is an event from `servers/events/bus.ts`, the
|
||||
* value a path on this plugin's own sidecar. Core POSTs `{ event, paths, email }` to it after the
|
||||
* operation has already succeeded.
|
||||
*
|
||||
* **A hook cannot fail the thing that triggered it.** The upload is done and answered before anybody
|
||||
* is told; a plugin that is down, slow or erroring gets a line in the log. Nothing here is a way to
|
||||
* veto or modify an operation, and that is deliberate — a hook that could refuse an upload would make
|
||||
* every upload's reliability the worst of every installed plugin's.
|
||||
*/
|
||||
hooks?: Partial<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* An MCP server this plugin serves, offered to the agent as its own.
|
||||
*
|
||||
* ── Why a path and not a URL ──
|
||||
*
|
||||
* The path is under this plugin's own mount, so `/mcp` becomes `/api/<plugin>/mcp` on THIS Officer.
|
||||
* A URL would let a plugin point the agent at any host, and the agent's config carries a token that
|
||||
* signs as the owner — so the one field that must not be free-form is the destination.
|
||||
*
|
||||
* ── Why this exists rather than wrapping the tools ──
|
||||
*
|
||||
* A plugin that fronts something with its own MCP server — openseo is the first — can either
|
||||
* reimplement that vocabulary as Officer tools or hand the real one over. Reimplementing means a
|
||||
* second copy that goes stale, and it fails silently: a tool exists upstream that Officer cannot see.
|
||||
* Handing it over costs this field.
|
||||
*
|
||||
* What a wrapper would have bought — authentication, logging, refusal — is already true of the path,
|
||||
* because the agent reaches it through Officer's own API with Officer's own gate in front. Wrapping
|
||||
* would buy it a second time.
|
||||
*/
|
||||
mcp?: {
|
||||
/** Under the plugin's mount. `/mcp` on a plugin mounted at `/api/openseo` is `/api/openseo/mcp`. */
|
||||
path: string;
|
||||
};
|
||||
|
||||
osDependencies?: OsDependency[];
|
||||
|
||||
/**
|
||||
@@ -597,7 +681,20 @@ export type DiscoveredPlugin = {
|
||||
* Both files are required when `web/` exists at all. Missing either is a broken plugin, not a plugin
|
||||
* with a partial frontend.
|
||||
*/
|
||||
web: { screens: DiscoveredScreen[] } | null;
|
||||
web: {
|
||||
screens: DiscoveredScreen[];
|
||||
/**
|
||||
* `web/file-actions.ts`, when the plugin has one.
|
||||
*
|
||||
* Presence is the declaration, as with `panels.ts` — the manifest says nothing about it because
|
||||
* there is nothing a human choice adds: an action carries its own label and decides for itself
|
||||
* whether it applies.
|
||||
*
|
||||
* Not per screen. The file browser's context menu is the same menu wherever it is mounted, so an
|
||||
* action belongs to the plugin.
|
||||
*/
|
||||
fileActions: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -905,6 +1002,24 @@ export function manifestProblems(appName: string, manifest: Partial<PluginManife
|
||||
}
|
||||
}
|
||||
}
|
||||
if (manifest.extraPorts !== undefined) {
|
||||
if (!Array.isArray(manifest.extraPorts)) {
|
||||
problems.push('extraPorts must be an array');
|
||||
} else {
|
||||
const seen = new Set<string>();
|
||||
for (const [i, entry] of manifest.extraPorts.entries()) {
|
||||
const name = (entry as { name?: unknown } | null)?.name;
|
||||
if (typeof name !== 'string' || !/^[a-z][a-z0-9-]{0,30}$/.test(name)) {
|
||||
problems.push(`extraPorts[${i}].name must be a lowercase name, e.g. "site"`);
|
||||
continue;
|
||||
}
|
||||
// The name becomes an environment variable, so two that differ only by case would collide there
|
||||
// while looking distinct here.
|
||||
if (seen.has(name)) problems.push(`extraPorts[${i}] repeats the name "${name}"`);
|
||||
seen.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (manifest.protocolHeaders !== undefined) {
|
||||
if (!Array.isArray(manifest.protocolHeaders)) {
|
||||
problems.push('protocolHeaders must be an array of header names');
|
||||
|
||||
@@ -39,6 +39,15 @@ export type CatalogueEntry = {
|
||||
appName: string;
|
||||
publisher: string;
|
||||
version: string;
|
||||
/**
|
||||
* The git tag this entry was published from — the newest RELEASE, as opposed to whatever a manifest
|
||||
* claims about itself.
|
||||
*
|
||||
* Optional, and absent means "cannot tell" rather than "no release": an older catalogue does not send
|
||||
* the field at all, and a plugin that has never been tagged has nothing to put in it. Both send the
|
||||
* badge back to the git rule.
|
||||
*/
|
||||
releaseTag?: string;
|
||||
platform: string;
|
||||
label: string;
|
||||
summary: string;
|
||||
@@ -556,7 +565,15 @@ const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.offi
|
||||
* plugin published this morning shows up this morning. The cache is per PROCESS and dies with it — a
|
||||
* restart is always a fresh look.
|
||||
*/
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
/*
|
||||
One minute, and the number changed when the badge started depending on it.
|
||||
|
||||
Five was fine while this only drew the store's tiles; it is not fine now that "is there an update" is
|
||||
answered from the catalogue, because the wait for a release to appear became the sync's minute PLUS
|
||||
this one. The cost is one small request per process per minute, and only while somebody is looking at
|
||||
a page that lists plugins.
|
||||
*/
|
||||
const CACHE_TTL_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* How long to wait for the store before giving up on it.
|
||||
@@ -639,6 +656,22 @@ export async function fetchCatalogue(): Promise<CatalogueEntry[]> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the store says the newest release of each plugin is, keyed by app name.
|
||||
*
|
||||
* This is the fact the update badge is built on. It replaces every host in the fleet running `git fetch`
|
||||
* against twenty-one repositories to re-derive something the catalogue already knows — see
|
||||
* `plugins/release.ts` for the rule and for what happens to a plugin that has no entry here.
|
||||
*
|
||||
* A missing key and a null value mean the same thing to a caller and are kept apart for the reader: no
|
||||
* entry at all (unregistered, or a store that could not be reached) versus an entry with no release yet.
|
||||
* Both fall through to the git rule, which is the only honest answer when the store cannot speak.
|
||||
*/
|
||||
export async function publishedTags(): Promise<Map<string, string | null>> {
|
||||
const entries = await fetchCatalogue().catch(() => []);
|
||||
return new Map(entries.map((e) => [e.appName, e.releaseTag ?? null]));
|
||||
}
|
||||
|
||||
/** One entry by app name, or null. Used by install to find where to clone from. */
|
||||
export async function catalogueEntry(appName: string): Promise<CatalogueEntry | null> {
|
||||
const all = await fetchCatalogue().catch(() => BUNDLED);
|
||||
|
||||
@@ -59,7 +59,13 @@ export type PluginsSnapshot = {
|
||||
* the tree while the row stayed, and there is nothing to mount, describe or offer. The row is left in the
|
||||
* database on purpose — deleting it here would turn "somebody moved the checkout" into silent data loss.
|
||||
*/
|
||||
export async function snapshotPlugins(): Promise<PluginsSnapshot> {
|
||||
/**
|
||||
* @param published Newest released tag per plugin, from the marketplace — `publishedTags()`. Passed by
|
||||
* the one caller that renders update badges, and by nothing else on purpose: every other caller wants
|
||||
* "what is on disk", and boot is among them. Making this function fetch the catalogue itself would put
|
||||
* a network request with a timeout in front of mounting plugins on a machine with no internet.
|
||||
*/
|
||||
export async function snapshotPlugins(published?: Map<string, string | null>): Promise<PluginsSnapshot> {
|
||||
const [{ plugins, broken }, installs] = await Promise.all([discoverPlugins(), listPluginInstalls()]);
|
||||
const byName = new Map(installs.map((row) => [row.appName, row]));
|
||||
|
||||
@@ -72,7 +78,7 @@ export async function snapshotPlugins(): Promise<PluginsSnapshot> {
|
||||
plugin,
|
||||
install,
|
||||
outdated: !!install && install.version !== plugin.manifest.version,
|
||||
release: await releaseState(plugin.dir),
|
||||
release: await releaseState(plugin.dir, published?.get(plugin.appName) ?? null),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { PLATFORM_DIR } from '../data-path';
|
||||
import type { DiscoveredPlugin } from './manifest';
|
||||
@@ -104,7 +105,33 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
|
||||
return { ok: true, ran: false, output: '', conflicts };
|
||||
}
|
||||
|
||||
const body = {
|
||||
/*
|
||||
Packages the PLATFORM owns, which a plugin may never bring its own copy of.
|
||||
|
||||
React holds module-level state — the hook dispatcher — so two copies in one page is not a duplicated
|
||||
dependency, it is a broken one. The second instance's `useRef` reads a null dispatcher and every panel
|
||||
fails to render with "Cannot read properties of null (reading 'useRef')", which names nothing that
|
||||
would lead somebody here.
|
||||
|
||||
It is easy to arrive at by accident and hard to diagnose: nobody asks for React. `@tiptap/react`
|
||||
declares it as a PEER, bun installs peers, and the copy lands in plugins/node_modules where it wins
|
||||
resolution for everything under it. That took the fofos-admin screen down the moment tiptap arrived.
|
||||
|
||||
Stripped rather than pinned. Matching the platform's version would still be two copies, and two copies
|
||||
is the whole problem — bun resolves up the tree, so removing them entirely is what makes a plugin use
|
||||
the one the platform already loaded.
|
||||
|
||||
Removed AFTER the install as well as before it, and the second is the one that does the work: no
|
||||
plugin declares React, so there is nothing to strip from the merged list. It arrives as a PEER of
|
||||
something that does, which bun installs and the generated file never mentions.
|
||||
*/
|
||||
const PLATFORM_OWNED = ['react', 'react-dom'];
|
||||
|
||||
for (const owned of PLATFORM_OWNED) {
|
||||
if (owned in merged) delete merged[owned];
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: '@officer/plugin-dependencies',
|
||||
private: true,
|
||||
'//': 'GENERATED from every plugin manifest by src/servers/plugins/npm-deps.ts. Not edited, not committed.',
|
||||
@@ -117,7 +144,26 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
|
||||
const proc = Bun.spawn(['bun', 'install'], { cwd: PLUGINS_DIR, stdout: 'pipe', stderr: 'pipe' });
|
||||
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
const code = await proc.exited;
|
||||
return { ok: code === 0, ran: true, output: `${out}${err}`.trim(), conflicts };
|
||||
|
||||
/*
|
||||
Evict the platform's singletons, however they got here.
|
||||
|
||||
This is the step that matters. Stripping the merged list above only covers a plugin ASKING for
|
||||
React, and none do — it arrives as a peer of something that does, so bun installs it without the
|
||||
generated file ever naming it. Deleting it afterwards is crude and it is deterministic, which is
|
||||
the right trade for a failure whose symptom is every panel on a screen refusing to render.
|
||||
*/
|
||||
const evicted: string[] = [];
|
||||
for (const owned of PLATFORM_OWNED) {
|
||||
const path = join(PLUGINS_DIR, 'node_modules', owned);
|
||||
if (existsSync(path)) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
evicted.push(owned);
|
||||
}
|
||||
}
|
||||
|
||||
const note = evicted.length ? `\nremoved ${evicted.join(', ')} — the platform owns those` : '';
|
||||
return { ok: code === 0, ran: true, output: `${out}${err}${note}`.trim(), conflicts };
|
||||
} catch (error) {
|
||||
return { ok: false, ran: true, output: error instanceof Error ? error.message : String(error), conflicts };
|
||||
}
|
||||
@@ -135,12 +181,31 @@ export async function installNpmDependencies(plugins: DiscoveredPlugin[]): Promi
|
||||
export function unresolvableDependencies(plugin: DiscoveredPlugin): string[] {
|
||||
const names = Object.keys(plugin.manifest.npmDependencies ?? {});
|
||||
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 {
|
||||
Bun.resolveSync(name, plugin.dir);
|
||||
return false;
|
||||
} 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'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,61 @@ describe('releaseState', () => {
|
||||
expect(s.updateable).toBe(true);
|
||||
});
|
||||
|
||||
/*
|
||||
The catalogue path. These are the ones that matter now: a host no longer fetches to find out what the
|
||||
newest release is, so every assertion below is about the store's tag winning over local refs — and
|
||||
about the fallback still being there when it has nothing to say.
|
||||
*/
|
||||
test('the published tag decides, without the checkout having ever fetched it', async () => {
|
||||
const { work, origin, clone } = pair('published-newer');
|
||||
|
||||
// The clone is at v1 and has NEVER seen v2 — no fetch, no remote ref for it. Exactly the state a
|
||||
// host is in a minute after somebody else tagged a release.
|
||||
publish(work, origin, 'two', 'v1');
|
||||
git(clone, 'fetch', '-q', '--tags', 'origin');
|
||||
git(clone, 'checkout', '-q', 'v1');
|
||||
|
||||
const s = await releaseState(clone, 'v2');
|
||||
expect(s.tagAtHead).toBe('v1');
|
||||
expect(s.newestTag).toBe('v2');
|
||||
expect(s.updateable).toBe(true);
|
||||
expect(s.reason).toBe('new-release');
|
||||
});
|
||||
|
||||
test('the published tag matching HEAD is up to date', async () => {
|
||||
const { work, origin, clone } = pair('published-same');
|
||||
publish(work, origin, 'two', 'v1');
|
||||
git(clone, 'fetch', '-q', '--tags', 'origin');
|
||||
git(clone, 'checkout', '-q', 'v1');
|
||||
|
||||
const s = await releaseState(clone, 'v1');
|
||||
expect(s.updateable).toBe(false);
|
||||
expect(s.reason).toBe('at-latest');
|
||||
});
|
||||
|
||||
test('an untagged checkout is updateable AND says what it would land on', async () => {
|
||||
const { clone } = pair('published-untagged');
|
||||
const s = await releaseState(clone, 'v9');
|
||||
expect(s.tagAtHead).toBeNull();
|
||||
// The old code returned null here, so the button read "Update" with nothing after it.
|
||||
expect(s.newestTag).toBe('v9');
|
||||
expect(s.updateable).toBe(true);
|
||||
expect(s.reason).toBe('untagged');
|
||||
});
|
||||
|
||||
test('no published tag falls back to the git rule rather than to a guess', async () => {
|
||||
const { work, origin, clone } = pair('no-entry');
|
||||
publish(work, origin, 'two', 'v1');
|
||||
git(clone, 'fetch', '-q', '--tags', 'origin');
|
||||
git(clone, 'checkout', '-q', 'v1');
|
||||
|
||||
// Null, as it is for a plugin nobody registered or a store that could not be reached.
|
||||
const s = await releaseState(clone, null);
|
||||
expect(s.tagAtHead).toBe('v1');
|
||||
expect(s.newestTag).toBe('v1');
|
||||
expect(s.updateable).toBe(false);
|
||||
});
|
||||
|
||||
test('a directory that is not a checkout is not updateable', async () => {
|
||||
const d = join(root, 'plain');
|
||||
mkdirSync(d, { recursive: true });
|
||||
|
||||
@@ -29,12 +29,32 @@ 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.
|
||||
* That holds for any tag names anyone ever chooses, including ones that do not sort.
|
||||
*
|
||||
* ── What this does NOT do ──
|
||||
* ── Where "the newest tag" comes from: the catalogue, not this machine's refs ──
|
||||
*
|
||||
* It never fetches. Every answer comes from refs already on disk, because this is called once per plugin
|
||||
* on a request that renders a list — twenty-one network round trips would make the plugins screen feel
|
||||
* 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.
|
||||
* Every answer here is local, and this is called once per plugin on a request that renders a list —
|
||||
* twenty-one network round trips would make the plugins screen feel broken. That left one hard problem:
|
||||
* refs on disk go stale. Tags arrived at clone and at the fetch `pullPlugin` does, so you learned an
|
||||
* update existed BY UPDATING. A machine that had not touched 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.
|
||||
*
|
||||
* That was patched with a rate-limited background `git fetch` per checkout, which worked and was the
|
||||
* wrong shape: every host in the fleet polling twenty-one repositories to re-derive a fact one place
|
||||
* already knows. The marketplace publishes releases now — see `servers/marketplace/sync.ts` — so the
|
||||
* newest release is simply asked of the catalogue, which every host already fetches for the store.
|
||||
*
|
||||
* published tag (catalogue) vs tag at HEAD (this checkout) → differ, so there is an update
|
||||
*
|
||||
* DIFFERENCE, not ordering. `2026.08.27.01` parses fine until somebody tags `v2`, and then it fails
|
||||
* silently. Two strings that are not equal is a comparison that cannot rot.
|
||||
*
|
||||
* ── The git rule stays, for a plugin the store cannot speak for ──
|
||||
*
|
||||
* A checkout of something unpublished — a plugin being written, one never registered — has no catalogue
|
||||
* entry, and the fallback below is the old rule against local remote-tracking refs. Those refs are no
|
||||
* longer refreshed here, so for that case this can miss a tag until something else fetches. That is the
|
||||
* accepted cost: the alternative was every machine polling every repository forever to cover a case that
|
||||
* only arises on a developer's own box, where `git fetch` is a thing they were about to run anyway.
|
||||
*/
|
||||
|
||||
export type ReleaseState = {
|
||||
@@ -53,7 +73,12 @@ const run = async (dir: string, args: string[]): Promise<string | null> => {
|
||||
return (await proc.exited) === 0 ? out.trim() || null : null;
|
||||
};
|
||||
|
||||
export async function releaseState(dir: string): Promise<ReleaseState> {
|
||||
/**
|
||||
* @param publishedTag The tag the marketplace says is this plugin's newest release, or null when the
|
||||
* catalogue has nothing to say — an unregistered plugin, one never tagged, or a store that could not
|
||||
* be reached. Null falls through to the git rule rather than to a guess.
|
||||
*/
|
||||
export async function releaseState(dir: string, publishedTag: string | null = null): Promise<ReleaseState> {
|
||||
const none = { tagAtHead: null, newestTag: null };
|
||||
if (!existsSync(join(dir, '.git'))) {
|
||||
// Copied in by hand or bundled. Nothing to compare, and nothing to pull either — `pullPlugin` says
|
||||
@@ -64,6 +89,16 @@ export async function releaseState(dir: string): Promise<ReleaseState> {
|
||||
// `--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.
|
||||
const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']);
|
||||
|
||||
if (publishedTag) {
|
||||
// Reported even when HEAD is untagged, so the UI can say what pressing Update lands on. The old
|
||||
// code returned a bare null here and the button read "Update" with nothing after it.
|
||||
if (!tagAtHead) return { tagAtHead: null, newestTag: publishedTag, updateable: true, reason: 'untagged' };
|
||||
return publishedTag === tagAtHead
|
||||
? { tagAtHead, newestTag: publishedTag, updateable: false, reason: 'at-latest' }
|
||||
: { tagAtHead, newestTag: publishedTag, updateable: true, reason: 'new-release' };
|
||||
}
|
||||
|
||||
if (!tagAtHead) return { ...none, updateable: true, reason: 'untagged' };
|
||||
|
||||
// Asked of the REMOTE branch, not of HEAD: a tag pushed after this checkout was made is not reachable
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readdirSync } from 'node:fs';
|
||||
import { publishedTags } from './marketplace';
|
||||
import { releaseState } from './release';
|
||||
import { join } from 'node:path';
|
||||
import { PLUGINS_DIR } from './discover';
|
||||
@@ -144,7 +145,10 @@ export async function updatePlugin(
|
||||
|
||||
Checked BEFORE the pull, because a pull is what would make the answer stale.
|
||||
*/
|
||||
const state = await releaseState(join(PLUGINS_DIR, appName));
|
||||
// Against the CATALOGUE, like the badge. Without it this asked local remote-tracking refs, which are
|
||||
// no longer refreshed in the background — so a plugin at the latest tag it knows about would have
|
||||
// short-circuited here and told the owner it was up to date without checking anything.
|
||||
const state = await releaseState(join(PLUGINS_DIR, appName), (await publishedTags()).get(appName) ?? null);
|
||||
if (!state.updateable && state.reason === 'at-latest') {
|
||||
steps.push(`already at ${state.tagAtHead}, the newest release`);
|
||||
onStep?.(steps[steps.length - 1]!);
|
||||
|
||||
@@ -18,6 +18,8 @@ import { createSidecarConnector } from '../connect';
|
||||
import { sign } from '../../jwt';
|
||||
import { getUserByEmail, getOwnerUser } from 'officerdb';
|
||||
import { DATA_PATH, findEmailAccountName } from '../../data-path';
|
||||
import { listPluginInstalls } from 'officerdb';
|
||||
import { discoverPlugins } from '../../plugins/discover';
|
||||
import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
|
||||
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
|
||||
@@ -140,7 +142,7 @@ loadState(dbUser.id);
|
||||
|
||||
// ── MCP config ──
|
||||
|
||||
function generateMcpConfig(): string {
|
||||
async function generateMcpConfig(): Promise<string> {
|
||||
// Was `.container-context`, from the architecture where each user ran inside their own Docker
|
||||
// container and this directory described that container to the agent. Nothing about it is
|
||||
// container-related now — it holds exactly one file, the MCP server config handed to the CLI. The
|
||||
@@ -152,8 +154,45 @@ function generateMcpConfig(): string {
|
||||
const userRoot = join(DATA_PATH, email!);
|
||||
|
||||
const hostToolsDirs = [globalToolsDir, ...(existsSync(userToolsDir) ? [userToolsDir] : [])].join(':');
|
||||
|
||||
/*
|
||||
MCP servers contributed by plugins.
|
||||
|
||||
A plugin that fronts something with its own MCP server can either reimplement that vocabulary as
|
||||
Officer tools or hand the real one over. Reimplementing means a second copy that goes stale, and it
|
||||
fails silently — a tool exists upstream that Officer cannot see. Handing it over costs this block.
|
||||
|
||||
The plugin declares a PATH, never a URL, and it is resolved against this Officer's own API. That is
|
||||
the whole safety property: the config below carries `OFFICER_AUTH_TOKEN`, a token that signs as the
|
||||
owner, so a manifest able to name an arbitrary host would be a manifest able to post the owner's
|
||||
credential anywhere. A path can only ever reach this machine, through this machine's own gate.
|
||||
|
||||
Installed AND enabled, because a disabled plugin's routes are not mounted — offering the agent a
|
||||
tool list that 404s is worse than not offering it.
|
||||
*/
|
||||
const pluginMcpServers: Record<string, { type: 'http'; url: string; headers: Record<string, string> }> = {};
|
||||
try {
|
||||
const [installs, discovered] = await Promise.all([listPluginInstalls(), discoverPlugins()]);
|
||||
const live = new Set(installs.filter((entry) => entry.enabled).map((entry) => entry.appName));
|
||||
|
||||
for (const plugin of discovered.plugins) {
|
||||
const declared = plugin.manifest?.mcp?.path;
|
||||
if (!declared || !live.has(plugin.appName)) continue;
|
||||
const path = declared.startsWith('/') ? declared : `/${declared}`;
|
||||
pluginMcpServers[plugin.appName] = {
|
||||
type: 'http',
|
||||
url: `${OFFICER_API_URL}/api/${plugin.appName}${path}`,
|
||||
headers: { Authorization: `Bearer ${OFFICER_AUTH_TOKEN}` },
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// A plugin that cannot be read must not stop an agent from starting. It loses its MCP tools for this
|
||||
// session and everything else works, which is the right side to fail on.
|
||||
}
|
||||
|
||||
const hostConfig = {
|
||||
mcpServers: {
|
||||
...pluginMcpServers,
|
||||
'officer-tools': {
|
||||
type: 'stdio',
|
||||
command: 'bun',
|
||||
@@ -236,7 +275,13 @@ function ensureAnthropicEnv(): void {
|
||||
// deliberately not written — it would pollute the personal global ~/.claude/CLAUDE.md that the
|
||||
// terminal `claude` loads too.
|
||||
|
||||
setMcpConfigPath(generateMcpConfig());
|
||||
/*
|
||||
Awaited, because the config now asks the database which plugins are installed.
|
||||
|
||||
Not fire-and-forget: `setMcpConfigPath` is what the agent manager reads when it spawns, so a config
|
||||
that arrives after the first spawn is an agent running without its plugin tools and no sign of why.
|
||||
*/
|
||||
setMcpConfigPath(await generateMcpConfig());
|
||||
ensureAnthropicEnv();
|
||||
|
||||
console.log(`[agent] started for ${email} (HOME=${homeDir})`);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState, useRef, useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { remarkMentions, MENTION_HREF } from './remark-mentions';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { remarkLineBreaks } from './remark-line-breaks';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
|
||||
import {
|
||||
@@ -202,7 +203,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
base64 blob pushes the bubble past max-w and out of the pane. */}
|
||||
<div className="chat-md break-words">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
@@ -248,7 +249,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry, onDecidePlan, defaul
|
||||
<div className="rounded-2xl rounded-tl-sm bg-muted/50 border border-border/50 px-4 py-2.5">
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
@@ -546,7 +547,7 @@ const PlanCard = ({
|
||||
</div>
|
||||
|
||||
<div className="max-h-96 overflow-y-auto break-words">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions]} components={MD_COMPONENTS}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]} components={MD_COMPONENTS}>
|
||||
{message.plan}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
@@ -637,7 +638,7 @@ export const StreamingBubble = ({ text }: StreamingBubbleProps) => {
|
||||
{text ? (
|
||||
<div className="chat-md">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMentions]}
|
||||
remarkPlugins={[remarkGfm, remarkMentions, remarkLineBreaks]}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, sanitizeSchema]]}
|
||||
components={MD_COMPONENTS}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* A screenshot, shrunk to the largest size the model will actually look at.
|
||||
*
|
||||
* ── Why there is a ceiling at all ──
|
||||
*
|
||||
* An attached image goes to the model as base64 INSIDE the message, and it stays there: every later
|
||||
* turn in the conversation re-sends every earlier picture. So a screenshot is not paid for once, it is
|
||||
* paid for on every turn for the rest of the session — in request bytes and in tokens.
|
||||
*
|
||||
* ── Why 1568 specifically ──
|
||||
*
|
||||
* It is the point past which Anthropic downscales server-side. A 2539px-wide screenshot is resized to
|
||||
* 1568 before the model sees a pixel of it, so the extra width was never read — it was carried up the
|
||||
* wire, counted, and thrown away. Sending it already small changes nothing about what the model can
|
||||
* see and stops paying for what it cannot.
|
||||
*
|
||||
* Measured on one day of this session: 25 screenshots, 10 of them over the line, 53,300 tokens of
|
||||
* image data — 23% of which bought nothing.
|
||||
*
|
||||
* ── The ORIGINAL is still uploaded ──
|
||||
*
|
||||
* This makes the copy that travels in the prompt. The file that lands on disk is untouched, so an agent
|
||||
* that needs the detail can `Read` it at full size — which is the right split: the conversation carries
|
||||
* what is worth re-reading every turn, and the disk keeps what was actually captured.
|
||||
*/
|
||||
const MAX_EDGE = 1568;
|
||||
|
||||
/** JPEG for photographs would be smaller, and a screenshot of text is exactly where it looks worst. */
|
||||
const TYPE = 'image/png';
|
||||
|
||||
export async function downscaleForPrompt(file: File): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file).catch(() => null);
|
||||
if (!bitmap) return readAsDataUrl(file);
|
||||
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
||||
// Already small enough. Re-encoding it would cost quality and could cost SIZE — a PNG round-tripped
|
||||
// through a canvas is often bigger than the one the screenshotter wrote.
|
||||
if (scale === 1) {
|
||||
bitmap.close();
|
||||
return readAsDataUrl(file);
|
||||
}
|
||||
|
||||
const width = Math.round(bitmap.width * scale);
|
||||
const height = Math.round(bitmap.height * scale);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
bitmap.close();
|
||||
return readAsDataUrl(file);
|
||||
}
|
||||
|
||||
// `high` because this is text: the default is a box filter, and screenshot text resampled with one
|
||||
// comes out fringed in a way that is harder to read than the same text smaller.
|
||||
context.imageSmoothingEnabled = true;
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
|
||||
return canvas.toDataURL(TYPE);
|
||||
}
|
||||
|
||||
/** The file as it is, for anything this cannot or should not shrink. */
|
||||
function readAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { remarkLineBreaks } from './remark-line-breaks';
|
||||
|
||||
type MdNode = { type: string; value?: string; children?: MdNode[] };
|
||||
|
||||
const run = (tree: MdNode): MdNode => {
|
||||
remarkLineBreaks()(tree);
|
||||
return tree;
|
||||
};
|
||||
|
||||
const para = (...children: MdNode[]): MdNode => ({ type: 'paragraph', children });
|
||||
const text = (value: string): MdNode => ({ type: 'text', value });
|
||||
|
||||
test('a single newline becomes a break', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('one\ntwo'))] });
|
||||
expect(tree.children![0]!.children).toEqual([
|
||||
{ type: 'text', value: 'one' },
|
||||
{ type: 'break' },
|
||||
{ type: 'text', value: 'two' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('several newlines make several breaks', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('a\nb\nc'))] });
|
||||
expect(tree.children![0]!.children!.filter((n) => n.type === 'break')).toHaveLength(2);
|
||||
});
|
||||
|
||||
// The empty-segment case: a trailing newline must give one break, never a break plus an empty text
|
||||
// node, which renders as an extra blank line and double-spaces everything after it.
|
||||
test('a trailing newline does not leave an empty text node', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('one\n'))] });
|
||||
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'one' }, { type: 'break' }]);
|
||||
});
|
||||
|
||||
// The property that makes this safe: by the time it runs, a newline inside a fence is the VALUE of a
|
||||
// `code` node, never a `text` node. Walking text alone cannot reach it.
|
||||
test('code blocks are untouched', () => {
|
||||
const code: MdNode = { type: 'code', value: 'line one\nline two' };
|
||||
const tree = run({ type: 'root', children: [code] });
|
||||
expect(tree.children![0]).toEqual({ type: 'code', value: 'line one\nline two' });
|
||||
});
|
||||
|
||||
test('inline code is untouched', () => {
|
||||
const tree = run({ type: 'root', children: [para({ type: 'inlineCode', value: 'a\nb' })] });
|
||||
expect(tree.children![0]!.children![0]).toEqual({ type: 'inlineCode', value: 'a\nb' });
|
||||
});
|
||||
|
||||
test('text without newlines is left exactly as it was', () => {
|
||||
const tree = run({ type: 'root', children: [para(text('nothing to do here'))] });
|
||||
expect(tree.children![0]!.children).toEqual([{ type: 'text', value: 'nothing to do here' }]);
|
||||
});
|
||||
|
||||
// Emphasis, links and mentions all nest text — the walk has to go into them, not just the top level.
|
||||
test('nested nodes are walked', () => {
|
||||
const tree = run({ type: 'root', children: [para({ type: 'emphasis', children: [text('one\ntwo')] })] });
|
||||
expect(tree.children![0]!.children![0]!.children).toEqual([
|
||||
{ type: 'text', value: 'one' },
|
||||
{ type: 'break' },
|
||||
{ type: 'text', value: 'two' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Make a single newline a line break, the way every chat app does.
|
||||
*
|
||||
* ── The message was never wrong ──
|
||||
*
|
||||
* Shift+Enter puts a real `\n` in the textarea and a real `\n` is stored, sent and given to the agent.
|
||||
* What loses it is MARKDOWN'S OWN RULE: inside a paragraph, a single line ending is whitespace, and only
|
||||
* a blank line starts a new paragraph. So
|
||||
*
|
||||
* one
|
||||
* two
|
||||
*
|
||||
* renders as "one two", correctly, per CommonMark. That is right for prose and wrong for a chat box,
|
||||
* where somebody pressing Shift+Enter is asking for a line.
|
||||
*
|
||||
* The fix therefore belongs at RENDER time. The alternative — appending two spaces to every line as the
|
||||
* message is saved — would edit what somebody typed on its way to disk, and every consumer downstream
|
||||
* (the agent, a transcript, an export) would carry trailing whitespace nobody asked for to satisfy a
|
||||
* display concern.
|
||||
*
|
||||
* This is what `remark-breaks` does. It is not in the lockfile, and `remark-mentions` beside this file
|
||||
* records the reasoning for not adding one: installs are frozen, and a dependency for a short tree walk
|
||||
* is a deliberate lockfile change for no benefit.
|
||||
*
|
||||
* ── Why the split is only inside text nodes ──
|
||||
*
|
||||
* By the time this runs the tree is already parsed, so a newline inside a code fence is the VALUE of a
|
||||
* `code` node and never a `text` node — walking text alone means fenced code, inline code and tables are
|
||||
* untouched. That is the property that makes this safe: it cannot reach anything where a newline means
|
||||
* something else.
|
||||
*/
|
||||
|
||||
type MdNode = { type: string; value?: string; children?: MdNode[] };
|
||||
|
||||
export function remarkLineBreaks() {
|
||||
return (tree: MdNode) => {
|
||||
const walk = (node: MdNode) => {
|
||||
if (!node.children?.length) return;
|
||||
|
||||
const next: MdNode[] = [];
|
||||
for (const child of node.children) {
|
||||
if (child.type !== 'text' || !child.value?.includes('\n')) {
|
||||
walk(child);
|
||||
next.push(child);
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
`one\ntwo` becomes text("one"), break, text("two").
|
||||
|
||||
Empty segments are dropped rather than emitted, so a trailing newline produces one break and
|
||||
not a break followed by an empty text node — which renders as an extra blank line and is the
|
||||
usual way this ends up double-spacing everything.
|
||||
*/
|
||||
const parts = child.value.split('\n');
|
||||
parts.forEach((part, index) => {
|
||||
if (index > 0) next.push({ type: 'break' });
|
||||
if (part) next.push({ type: 'text', value: part });
|
||||
});
|
||||
}
|
||||
|
||||
node.children = next;
|
||||
};
|
||||
|
||||
walk(tree);
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { downscaleForPrompt } from './components/downscale';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { Attachment } from './types';
|
||||
@@ -52,11 +53,22 @@ export function useAttachments(params?: UseAttachmentsParams) {
|
||||
if (params?.sessionId) formData.append('sessionId', params.sessionId);
|
||||
formData.append('provider', 'chat');
|
||||
|
||||
const res = await client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData);
|
||||
/*
|
||||
The original is uploaded and a SMALLER copy is what travels in the prompt.
|
||||
|
||||
The server's `dataUrl` is the file byte for byte, and that copy is re-sent on every turn for the
|
||||
rest of the conversation. Past 1568px on the long edge the model never sees the extra pixels —
|
||||
they are downscaled server-side before it looks — so the width was being carried and counted and
|
||||
then thrown away. See downscale.ts.
|
||||
|
||||
Both, deliberately: the disk keeps what was captured, so `Read` can still open it at full size.
|
||||
*/
|
||||
const [res, dataUrl] = await Promise.all([
|
||||
client.post<{ filename: string; dataUrl: string; attachmentId: string }>('/upload', formData),
|
||||
downscaleForPrompt(file),
|
||||
]);
|
||||
setAttachments((prev) =>
|
||||
prev.map((a, i) =>
|
||||
i === idx ? { ...a, dataUrl: res.dataUrl, attachmentId: res.attachmentId, loading: false } : a,
|
||||
),
|
||||
prev.map((a, i) => (i === idx ? { ...a, dataUrl, attachmentId: res.attachmentId, loading: false } : a)),
|
||||
);
|
||||
} catch {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== idx));
|
||||
|
||||
@@ -21,6 +21,14 @@ type FileTreeProps = {
|
||||
* an agent had just written to showing the listing from before its turn.
|
||||
*/
|
||||
refreshToken?: number;
|
||||
/**
|
||||
* A file to reveal: every directory on the way to it opens, loading as it goes.
|
||||
*
|
||||
* For a tree whose selection is driven from OUTSIDE — a nav beside it, a URL — where a folder left
|
||||
* shut around the open file hides the one thing being looked at. Expanding is still disclosure and
|
||||
* still remembered, so anything opened by hand stays open.
|
||||
*/
|
||||
revealPath?: string | null;
|
||||
};
|
||||
|
||||
type TreeNodeProps = {
|
||||
@@ -31,6 +39,7 @@ type TreeNodeProps = {
|
||||
searchForFile?: ((path: string) => string) | null;
|
||||
depth: number;
|
||||
refreshToken?: number;
|
||||
revealPath?: string | null;
|
||||
};
|
||||
|
||||
const FileIcon = ({ name }: { name: string }) => {
|
||||
@@ -44,7 +53,16 @@ const sortEntries = (entries: DirEntry[]) => {
|
||||
return [...dirs, ...files];
|
||||
};
|
||||
|
||||
const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, refreshToken }: TreeNodeProps) => {
|
||||
const TreeNode = ({
|
||||
entry,
|
||||
parentPath,
|
||||
root,
|
||||
onOpenFile,
|
||||
searchForFile,
|
||||
depth,
|
||||
refreshToken,
|
||||
revealPath,
|
||||
}: TreeNodeProps) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<DirEntry[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -73,6 +91,32 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
|
||||
};
|
||||
}, [refreshToken]);
|
||||
|
||||
/*
|
||||
On the way to the revealed file: open, and load if this is the first time.
|
||||
|
||||
Only ever opens. A node that closes itself when the selection moves elsewhere would collapse the
|
||||
tree under somebody as they clicked around it.
|
||||
*/
|
||||
const onTheWay = !!revealPath && isDir && revealPath.startsWith(`${fullPath}/`);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onTheWay) return;
|
||||
setExpanded(true);
|
||||
if (children !== null) return;
|
||||
|
||||
let alive = true;
|
||||
listDirRef
|
||||
.current(fullPath)
|
||||
.then((res) => alive && setChildren(sortEntries(res.entries)))
|
||||
.catch(() => alive && setChildren([]));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
// `children` is deliberately absent: this must run when the PATH changes, not when the load it
|
||||
// started completes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onTheWay, fullPath]);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (!isDir) {
|
||||
onOpenFile(fullPath, entry.name);
|
||||
@@ -140,6 +184,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
|
||||
searchForFile={searchForFile}
|
||||
depth={depth + 1}
|
||||
refreshToken={refreshToken}
|
||||
revealPath={revealPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -148,7 +193,7 @@ const TreeNode = ({ entry, parentPath, root, onOpenFile, searchForFile, depth, r
|
||||
);
|
||||
};
|
||||
|
||||
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken }: FileTreeProps) => {
|
||||
export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshToken, revealPath }: FileTreeProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { listDir } = useFilesAPI(root);
|
||||
const [entries, setEntries] = useState<DirEntry[] | null>(null);
|
||||
@@ -208,6 +253,7 @@ export const FileTree = ({ root, basePath, onOpenFile, searchForFile, refreshTok
|
||||
searchForFile={searchForFile}
|
||||
depth={0}
|
||||
refreshToken={refreshToken}
|
||||
revealPath={revealPath}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,9 @@ import { CodeEditorHeader } from './CodeEditorHeader';
|
||||
import { panelName } from '../../locales';
|
||||
|
||||
export { CodeEditorView } from './CodeEditor';
|
||||
// The tree on its own, for a panel that wants the editor's file list against its own directory rather
|
||||
// than the whole editor. It only LISTS — opening is the caller's callback.
|
||||
export { FileTree } from './FileTree';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Breadcrumb } from './components/Breadcrumb';
|
||||
import { Toolbar } from './components/Toolbar';
|
||||
import { UploadProgress } from './components/UploadProgress';
|
||||
@@ -6,11 +7,20 @@ import { TaskRunnerDialog } from './components/TaskRunnerDialog';
|
||||
import { AgentRunnerDialog } from './components/AgentRunnerDialog';
|
||||
import { VideoDownloadDialog } from './components/VideoDownloadDialog';
|
||||
import { DictateDialog } from './components/DictateDialog';
|
||||
import { useFileBrowserApp } from './useFileBrowserApp';
|
||||
import { useFileBrowserApp, type PickedFile } from './useFileBrowserApp';
|
||||
|
||||
type FileBrowserAppProps = {
|
||||
basePath?: string;
|
||||
rootOverride?: string;
|
||||
/** What the root crumb is called. Defaults to the base folder's own name. */
|
||||
rootLabel?: string;
|
||||
/**
|
||||
* Keep the current folder in this query param, on the route you are already on.
|
||||
*
|
||||
* For an embedded browser that wants a reloadable, shareable location. NOT `urlPath`, which owns the
|
||||
* pathname and navigates to `/files` — passing that from a panel takes the whole app with it.
|
||||
*/
|
||||
pathParam?: string;
|
||||
/** Put the current folder in `?path=`. Opt-in — only the /files screen is guaranteed one browser. */
|
||||
urlPath?: boolean;
|
||||
/**
|
||||
@@ -18,11 +28,44 @@ type FileBrowserAppProps = {
|
||||
* where the folder is the URL and already does.
|
||||
*/
|
||||
panelId?: string;
|
||||
/**
|
||||
* Turn this into a CHOOSER: opening a file hands it back instead of opening a viewer.
|
||||
*
|
||||
* Folders still navigate, because choosing a file means getting to it. A prop rather than a second
|
||||
* component — a picker built separately is a listing, a breadcrumb, a sort, thumbnails and keyboard
|
||||
* handling reimplemented, all of it free to drift from the real browser.
|
||||
*/
|
||||
onPickFile?: (file: PickedFile) => void;
|
||||
/**
|
||||
* The folder being shown, whenever it changes.
|
||||
*
|
||||
* For a chooser whose answer is a FOLDER rather than a file. Folders navigate rather than being
|
||||
* pickable — getting to a file requires it — so the only way to say "this one" is to be standing in
|
||||
* it, and the caller needs to know which one that is to put a button on it.
|
||||
*/
|
||||
onPathChange?: (path: string) => void;
|
||||
};
|
||||
|
||||
export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath, panelId }: FileBrowserAppProps) => {
|
||||
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId);
|
||||
const { handleNavigate } = fileBrowserManager;
|
||||
export const FileBrowserApp = ({
|
||||
basePath = '/',
|
||||
rootOverride,
|
||||
rootLabel,
|
||||
urlPath,
|
||||
panelId,
|
||||
pathParam,
|
||||
onPickFile,
|
||||
onPathChange,
|
||||
}: FileBrowserAppProps) => {
|
||||
const fileBrowserManager = useFileBrowserApp(basePath, rootOverride, urlPath, panelId, pathParam, onPickFile);
|
||||
const { handleNavigate, currentPath } = fileBrowserManager;
|
||||
|
||||
// Reported from here rather than threaded through the hook: the hook's job is the folder, and one
|
||||
// more positional argument on a six-argument call is how a signature stops being readable.
|
||||
const report = useRef(onPathChange);
|
||||
report.current = onPathChange;
|
||||
useEffect(() => {
|
||||
report.current?.(currentPath);
|
||||
}, [currentPath]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
@@ -31,6 +74,7 @@ export const FileBrowserApp = ({ basePath = '/', rootOverride, urlPath, panelId
|
||||
path={fileBrowserManager.currentPath}
|
||||
onNavigate={handleNavigate}
|
||||
basePath={basePath}
|
||||
rootLabel={rootLabel}
|
||||
hrefForPath={fileBrowserManager.hrefForPath}
|
||||
/>
|
||||
<UploadProgress fileBrowserManager={fileBrowserManager} />
|
||||
|
||||
+10
-2
@@ -5,6 +5,14 @@ type BreadcrumbProps = {
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
basePath?: string;
|
||||
/**
|
||||
* What to call the root crumb.
|
||||
*
|
||||
* Defaults to the base folder's own name, which is right for `/files` and wrong for an embedder: a
|
||||
* panel scoped to `<root>/media/originals` is called Media, and "originals" is an implementation
|
||||
* detail of how that plugin lays out a directory. The person looking at it never chose that word.
|
||||
*/
|
||||
rootLabel?: string;
|
||||
/**
|
||||
* Where a folder lives, when this browser owns the address bar. Given, the crumbs are real links and
|
||||
* cmd-click opens the folder in a new tab; absent (a dashboard panel, the widget) they stay buttons,
|
||||
@@ -15,7 +23,7 @@ type BreadcrumbProps = {
|
||||
hrefForPath?: ((path: string) => { pathname: string; search: string }) | null;
|
||||
};
|
||||
|
||||
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: BreadcrumbProps) => {
|
||||
export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath, rootLabel }: BreadcrumbProps) => {
|
||||
const relativePath = basePath !== '/' && path.startsWith(basePath) ? path.slice(basePath.length) : path;
|
||||
const segments = relativePath.split('/').filter(Boolean);
|
||||
|
||||
@@ -35,7 +43,7 @@ export const Breadcrumb = ({ path, onNavigate, basePath = '/', hrefForPath }: Br
|
||||
<nav className="flex items-center gap-1 text-sm flex-wrap">
|
||||
<Crumb to={basePath} className="flex items-center gap-1 text-duck-teal cursor-pointer font-medium">
|
||||
<Home className="h-4 w-4" />
|
||||
<span>{basePath === '/' ? 'home' : basePath.split('/').pop()}</span>
|
||||
<span>{rootLabel ?? (basePath === '/' ? 'home' : basePath.split('/').pop())}</span>
|
||||
</Crumb>
|
||||
|
||||
{segments.map((segment, i) => {
|
||||
|
||||
+43
-1
@@ -4,6 +4,8 @@ import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||
import { getFileType } from '../../../FileViewer';
|
||||
import type { UseFileBrowserAppType } from '../useFileBrowserApp';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { fileActionsFor } from '../../file-actions';
|
||||
import { FileItem } from './FileItem';
|
||||
import { useTranslation } from '../../../../locales';
|
||||
|
||||
@@ -15,7 +17,19 @@ type FileGridProps = {
|
||||
};
|
||||
|
||||
const LIST_ROW_HEIGHT = 42;
|
||||
const GRID_ROW_HEIGHT = 130;
|
||||
/*
|
||||
What one grid row actually occupies, and why the number changed.
|
||||
|
||||
It was 130 while the card was `p-4` around a 64px thumbnail plus two lines of text — about 148. The
|
||||
card overflowed its own row by roughly the size of the gap, so consecutive rows touched and the grid
|
||||
looked vertically stacked. The horizontal gap was fine, which is what made it read as a styling
|
||||
oddity rather than a measurement being wrong.
|
||||
|
||||
Now: a 96px image band, then `py-2` (16) around a 20px name and a 16px meta line, inside a 2px
|
||||
border. 96 + 16 + 20 + 16 + 4 = 152. Measured against the card rather than guessed, because guessing
|
||||
is how it came to be 130.
|
||||
*/
|
||||
const GRID_ROW_HEIGHT = 152;
|
||||
const GRID_GAP = 12;
|
||||
const HEADER_HEIGHT = 36;
|
||||
// Type-ahead: keys pressed within this window accumulate into one burst. 1–2 chars jump to the first
|
||||
@@ -49,6 +63,8 @@ const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
|
||||
export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
const { t } = useTranslation();
|
||||
// For plugin actions: `run` is outside React and cannot call the hook itself.
|
||||
const client = useClient();
|
||||
const {
|
||||
visibleEntries: entries,
|
||||
viewMode,
|
||||
@@ -56,6 +72,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
rootOverride,
|
||||
selected,
|
||||
clipboard,
|
||||
refresh,
|
||||
handleOpen,
|
||||
handleDelete,
|
||||
handleRename,
|
||||
@@ -155,8 +172,23 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
const cols = useColumnCount(scrollRef);
|
||||
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
||||
const anySelected = selected.size > 0;
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
|
||||
const pluginActionsFor = (entry: DirEntry) => {
|
||||
const names = selected.has(entry.name) && selected.size > 1 ? [...selected] : [entry.name];
|
||||
const payload = entries
|
||||
.filter((candidate) => names.includes(candidate.name))
|
||||
.map((candidate) => ({ name: candidate.name, path: entryPath(candidate.name), type: candidate.type }));
|
||||
|
||||
return fileActionsFor(payload).map((action) => ({
|
||||
key: action.key,
|
||||
label: action.label,
|
||||
Icon: action.icon,
|
||||
onClick: () => action.run(payload, { currentPath, refresh, client }),
|
||||
}));
|
||||
};
|
||||
|
||||
const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
@@ -257,6 +289,14 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
onReadAloud={handleReadAloud}
|
||||
onExtract={handleExtract}
|
||||
taskGroups={getMatchingTaskGroups(entry.name, entry.type)}
|
||||
/*
|
||||
Plugin actions, resolved here because this is where the SELECTION lives.
|
||||
|
||||
An action receives the whole selection when the right-clicked item is part of one, and just
|
||||
that item otherwise — what every file manager does. Acting on a selection the click was not
|
||||
part of is the surprising alternative.
|
||||
*/
|
||||
pluginActions={pluginActionsFor(entry)}
|
||||
onRunTask={handleRunTask}
|
||||
agentGroups={getMatchingAgentGroups(entry.name, entry.type)}
|
||||
onRunAgent={handleRunAgent}
|
||||
@@ -265,6 +305,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
onCompress={handleCompress}
|
||||
onDuplicate={handleDuplicate}
|
||||
onPasteInto={handlePasteInto}
|
||||
onDragStart={fileBrowserManager.handleEntryDragStart}
|
||||
onDropInto={fileBrowserManager.handleEntryDrop}
|
||||
onTogglePin={handleTogglePin}
|
||||
isPinned={isPinned(entryPath(entry.name))}
|
||||
canPaste={clipboard !== null}
|
||||
|
||||
+146
-21
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
Link2,
|
||||
Trash2,
|
||||
Pencil,
|
||||
MoreVertical,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
FileArchive,
|
||||
Pin,
|
||||
PinOff,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { Thumbnail } from './Thumbnail';
|
||||
@@ -50,7 +52,7 @@ import { cardStyle } from '@/components/Card';
|
||||
import type { DirEntry } from '../../../../hooks/useFilesAPI';
|
||||
import type { TaskGroup, TaskSummary } from '../../useTasks';
|
||||
import type { AgentGroup, AgentSummary } from '../../useAgents';
|
||||
import { getFileType } from '../../../FileViewer';
|
||||
import { getFileType, thumbVersion } from '../../../FileViewer';
|
||||
import { useTranslation } from '../../../../locales';
|
||||
import { useFormat } from 'i18n';
|
||||
|
||||
@@ -84,9 +86,25 @@ export type FileItemProps = {
|
||||
onRunAgent: (agent: AgentSummary, entry: DirEntry) => void;
|
||||
onCreateDashboard: (entry: DirEntry) => void;
|
||||
onOpenInEditor: (entry: DirEntry) => void;
|
||||
/**
|
||||
* Context-menu actions contributed by plugins, already resolved against the selection.
|
||||
*
|
||||
* Resolved by the caller, not here: whether an action receives one entry or the whole selection is a
|
||||
* question about the SELECTION, which this component does not have and should not need.
|
||||
*/
|
||||
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
|
||||
onCompress: (entry: DirEntry) => void;
|
||||
onDuplicate: (entry: DirEntry) => void;
|
||||
onPasteInto: (entry: DirEntry) => void;
|
||||
/**
|
||||
* Dragging this row, and dropping onto it.
|
||||
*
|
||||
* A custom MIME type on the DataTransfer survives between windows of the same browser, which is what
|
||||
* makes "drag from one Officer window into another" work at all. See useFileBrowserApp for the type
|
||||
* and why `Files` is left alone.
|
||||
*/
|
||||
onDragStart: (ev: React.DragEvent, entry: DirEntry) => void;
|
||||
onDropInto: (ev: React.DragEvent, entry: DirEntry) => Promise<void>;
|
||||
onTogglePin: (entry: DirEntry) => void;
|
||||
isPinned: boolean;
|
||||
canPaste: boolean;
|
||||
@@ -177,6 +195,7 @@ type MenuItemsProps = {
|
||||
canPaste: boolean;
|
||||
onOpen: (e: DirEntry) => void;
|
||||
onOpenInEditor: (e: DirEntry) => void;
|
||||
pluginActions?: { key: string; label: string; Icon?: React.ComponentType<{ className?: string }>; onClick: () => void }[];
|
||||
onDelete: (e: DirEntry) => void;
|
||||
onStartRename: () => void;
|
||||
onChat: (e: DirEntry) => void;
|
||||
@@ -212,6 +231,7 @@ const MenuItems = ({
|
||||
canPaste,
|
||||
onOpen,
|
||||
onOpenInEditor,
|
||||
pluginActions,
|
||||
onDelete,
|
||||
onStartRename,
|
||||
onChat,
|
||||
@@ -254,7 +274,7 @@ const MenuItems = ({
|
||||
|
||||
const hasTasks = taskGroups.length > 0;
|
||||
const hasAgents = agentGroups.length > 0;
|
||||
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents;
|
||||
const hasActions = showReadAloud || showExtract || hasTasks || hasAgents || Boolean(pluginActions?.length);
|
||||
// Only nest when more than one category matched — a file usually matches a single category, and
|
||||
// Run Task > Video > Convert would just add a hop.
|
||||
const nestTasks = taskGroups.length > 1;
|
||||
@@ -305,6 +325,14 @@ const MenuItems = ({
|
||||
{t('files.menu.extract')}
|
||||
</Item>
|
||||
)}
|
||||
{/* Plugin actions, flat rather than nested. There are few of them and each is one click; a
|
||||
submenu for two items is a hop that buys nothing. */}
|
||||
{pluginActions?.map((action) => (
|
||||
<Item key={action.key} onClick={action.onClick} className="cursor-pointer">
|
||||
{action.Icon ? <action.Icon className="mr-2 h-4 w-4" /> : <Sparkles className="mr-2 h-4 w-4" />}
|
||||
{action.label}
|
||||
</Item>
|
||||
))}
|
||||
{hasTasks && (
|
||||
<Sub>
|
||||
<SubTrigger className="cursor-pointer">
|
||||
@@ -565,6 +593,7 @@ export const FileItem = ({
|
||||
viewMode,
|
||||
selected,
|
||||
anySelected,
|
||||
pluginActions,
|
||||
selectedCount,
|
||||
isCut,
|
||||
onOpen,
|
||||
@@ -589,6 +618,8 @@ export const FileItem = ({
|
||||
onCompress,
|
||||
onDuplicate,
|
||||
onPasteInto,
|
||||
onDragStart,
|
||||
onDropInto,
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
canPaste,
|
||||
@@ -601,6 +632,21 @@ export const FileItem = ({
|
||||
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
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 ? (
|
||||
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
|
||||
) : (
|
||||
@@ -624,7 +670,16 @@ export const FileItem = ({
|
||||
rounded box would trim content off a photograph for decoration.
|
||||
*/
|
||||
const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => (
|
||||
<Thumbnail path={path} root={root} size={size} isVideo={kind === 'video'} className={className}>
|
||||
<Thumbnail
|
||||
path={path}
|
||||
root={root}
|
||||
size={size}
|
||||
// Which version of the file, so replacing or renaming one does not leave the old picture in a
|
||||
// browser cache that was told it could keep it for a year. See `Thumbnail`.
|
||||
version={thumbVersion(entry)}
|
||||
isVideo={kind === 'video'}
|
||||
className={className}
|
||||
>
|
||||
{node}
|
||||
</Thumbnail>
|
||||
);
|
||||
@@ -641,7 +696,20 @@ export const FileItem = ({
|
||||
sharp enough for a retina screen at both. One key, one render, and the views cannot diverge.
|
||||
*/
|
||||
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 256) : icon;
|
||||
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
|
||||
|
||||
/*
|
||||
The grid picture fills the card's WIDTH and is cropped by height.
|
||||
|
||||
It used to be a fixed `h-16 w-20` box centred with `object-contain`, so a landscape photo sat in the
|
||||
middle of the card with air either side and a portrait one shrank to nothing. `w-full` with the
|
||||
card's own `overflow-hidden` doing the clipping means every tile reads as the same shape whatever
|
||||
the photograph is — which is the whole reason a grid of pictures is easier to scan than a grid of
|
||||
names.
|
||||
|
||||
No rounding here: the card clips, so rounding the image too would show a sliver of card colour
|
||||
inside the corner.
|
||||
*/
|
||||
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-24 w-full', 256) : iconLarge;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -722,6 +790,10 @@ export const FileItem = ({
|
||||
onCreateDashboard,
|
||||
onOpen,
|
||||
onOpenInEditor,
|
||||
// Typed on both ends and forwarded by neither, which is why "Create Gallery" was in the bundle and
|
||||
// not in the menu. `menuProps` is assembled field by field, so a new prop is not carried by being
|
||||
// declared.
|
||||
pluginActions,
|
||||
onCompress,
|
||||
onDuplicate,
|
||||
onPasteInto,
|
||||
@@ -744,6 +816,24 @@ export const FileItem = ({
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, entry)}
|
||||
/*
|
||||
A folder is a drop target; a file is not. `onDragOver` must preventDefault or the browser
|
||||
refuses the drop entirely — that is the single line whose absence makes a target look
|
||||
dead, with no error anywhere.
|
||||
|
||||
`dropEffect` is set here rather than at dragstart because only the target knows whether
|
||||
it can accept: the cursor says "move" over a folder and "no" over a file, which is the
|
||||
only feedback a drag has.
|
||||
*/
|
||||
onDragOver={(ev) => {
|
||||
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(ev) => void onDropInto(ev, entry)}
|
||||
>
|
||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||
<span className="shrink-0">{listVisual}</span>
|
||||
@@ -755,7 +845,18 @@ export const FileItem = ({
|
||||
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>
|
||||
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
|
||||
@@ -784,13 +885,22 @@ export const FileItem = ({
|
||||
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
|
||||
<div
|
||||
data-file-item
|
||||
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
|
||||
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors ${
|
||||
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
|
||||
} ${cutOpacity} select-none`}
|
||||
style={selected ? undefined : cardStyle()}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
draggable
|
||||
onDragStart={(ev) => onDragStart(ev, entry)}
|
||||
onDragOver={(ev) => {
|
||||
if (entry.type !== 'directory' || !ev.dataTransfer.types.includes('application/x-officer-files')) return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
ev.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(ev) => void onDropInto(ev, entry)}
|
||||
>
|
||||
<div className="absolute top-1 left-1">
|
||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||
@@ -800,23 +910,38 @@ export const FileItem = ({
|
||||
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
|
||||
</div>
|
||||
|
||||
{gridVisual}
|
||||
{/* One fixed-height band whether it holds a photograph or a folder icon, so every tile is
|
||||
the same shape and the rows line up. The card clips; this does not round. */}
|
||||
<div className="flex h-24 w-full shrink-0 items-center justify-center overflow-hidden">{gridVisual}</div>
|
||||
|
||||
{renaming ? (
|
||||
<InlineRenameInput
|
||||
initialName={entry.name}
|
||||
onCommit={handleCommitRename}
|
||||
onCancel={() => setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-duck-dark text-center truncate w-full">{entry.name}</span>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-col items-center gap-0.5 px-2 py-2">
|
||||
{renaming ? (
|
||||
<InlineRenameInput
|
||||
initialName={entry.name}
|
||||
onCommit={handleCommitRename}
|
||||
onCancel={() => setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<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">
|
||||
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
|
||||
{' · '}
|
||||
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
<span className="text-xs text-duck-dark/50">
|
||||
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
|
||||
{' · '}
|
||||
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-[600]">
|
||||
|
||||
+13
-1
@@ -52,6 +52,7 @@ export const Thumbnail = ({
|
||||
path,
|
||||
root,
|
||||
size,
|
||||
version,
|
||||
isVideo,
|
||||
className,
|
||||
children,
|
||||
@@ -59,6 +60,16 @@ export const Thumbnail = ({
|
||||
/** Root-relative, the same path every other file-browser call uses. */
|
||||
path: string;
|
||||
root?: string;
|
||||
/**
|
||||
* Which VERSION of the file this is — `mtime-size`, from the listing.
|
||||
*
|
||||
* The response is `immutable` for a year, and the URL used to carry only the path, so a browser that
|
||||
* had drawn a thumbnail for a name went on drawing it however the file behind that name changed. A
|
||||
* rename into a name already seen showed the picture that used to be there, and nothing short of a
|
||||
* hard reload cleared it. The SERVER was never wrong — its own cache key has always held mtime and
|
||||
* size — the URL simply did not say which file it meant. See `getThumbUrl`.
|
||||
*/
|
||||
version?: string;
|
||||
/** What to ask the server for. Snapped server-side, so a near-enough number is fine. */
|
||||
size: number;
|
||||
/** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */
|
||||
@@ -79,7 +90,8 @@ export const Thumbnail = ({
|
||||
|
||||
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
|
||||
const versionParam = version ? `&v=${encodeURIComponent(version)}` : '';
|
||||
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`;
|
||||
|
||||
return (
|
||||
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
|
||||
|
||||
+34
-9
@@ -1,5 +1,5 @@
|
||||
import { useRef } from 'react';
|
||||
import { FolderPlus, Upload, FolderUp, ClipboardPaste } from 'lucide-react';
|
||||
import { FolderPlus, Upload, FolderUp, ClipboardPaste, X } from 'lucide-react';
|
||||
import type { UseFileBrowserAppType } from '../../useFileBrowserApp';
|
||||
import { useTranslation } from '../../../../../locales';
|
||||
|
||||
@@ -9,7 +9,7 @@ type DefaultActionsProps = {
|
||||
|
||||
export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setNewEntry, handleUpload, clipboard, handlePaste } = fileBrowserManager;
|
||||
const { setNewEntry, handleUpload, clipboard, handlePaste, setClipboard } = fileBrowserManager;
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
@@ -56,14 +56,39 @@ export const DefaultActions = ({ fileBrowserManager }: DefaultActionsProps) => {
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/*
|
||||
What is on the clipboard, said out loud.
|
||||
|
||||
It used to be an icon and a toast. That was enough while the clipboard died with the tab — you
|
||||
had just pressed the key, so you knew what was on it. Now it is per-user and survives reloads,
|
||||
tabs and days, which means somebody can arrive at a folder with three files pending from
|
||||
yesterday and paste them without ever having been told. Invisible persistent state is how a
|
||||
file gets moved by accident.
|
||||
|
||||
So it says how many and whether they will be copied or moved, and offers a way to let go of
|
||||
them. `cut` is worth distinguishing in the label rather than only in the icon: it is the one
|
||||
that takes the originals with it.
|
||||
*/}
|
||||
{clipboard !== null && (
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
title={t('files.menu.paste')}
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 rounded-md bg-duck-dark/5 pl-2">
|
||||
<span className="text-xs text-duck-teal whitespace-nowrap">
|
||||
{clipboard.paths.length} {clipboard.mode === 'cut' ? t('files.toMove') : t('files.toCopy')}
|
||||
</span>
|
||||
<button
|
||||
onClick={handlePaste}
|
||||
title={t('files.menu.paste')}
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/10 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClipboard(null)}
|
||||
title={t('files.clearClipboard')}
|
||||
className="p-1.5 rounded-md text-duck-teal/60 hover:bg-duck-dark/10 hover:text-duck-teal cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
+64
-39
@@ -213,7 +213,11 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
@@ -248,52 +252,73 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
||||
</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) => (
|
||||
<div
|
||||
key={`${e.url}-${i}`}
|
||||
className="flex items-center gap-3 rounded-md border border-duck-dark/10 bg-background/40 p-2"
|
||||
/*
|
||||
A COLUMN, not a row. The title, the thumbnail and the button used to share one
|
||||
line, so a long title — which is most of them — competed with the button for width
|
||||
and won: `truncate` cannot bite while a sibling can be pushed out of the card.
|
||||
Split, the title has the whole width to truncate against and the button has a row
|
||||
it cannot be squeezed out of.
|
||||
*/
|
||||
className="flex flex-col gap-2 rounded-md border border-duck-dark/10 bg-background/40 p-2"
|
||||
>
|
||||
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
|
||||
{e.status === 'loading' ? (
|
||||
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||
) : e.status === 'error' ? (
|
||||
<AlertCircle size={16} className="text-destructive/70" />
|
||||
) : e.thumbnail && !audioOnly ? (
|
||||
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<Music size={16} className="text-duck-dark/40" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<div className="flex h-12 w-20 shrink-0 items-center justify-center overflow-hidden rounded bg-duck-dark/10">
|
||||
{e.status === 'loading' ? (
|
||||
<Loader2 size={16} className="animate-spin text-duck-dark/40" />
|
||||
) : e.status === 'error' ? (
|
||||
<AlertCircle size={16} className="text-destructive/70" />
|
||||
) : e.thumbnail && !audioOnly ? (
|
||||
<img src={e.thumbnail} alt="" className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<Music size={16} className="text-duck-dark/40" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
{e.status === 'loading' ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
|
||||
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
|
||||
</div>
|
||||
) : e.status === 'error' ? (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-destructive">
|
||||
{t('videoDownload.couldNotFetch')}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
|
||||
{e.title || e.url}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">
|
||||
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
|
||||
</>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{e.status === 'loading' ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="h-3 w-3/4 rounded bg-duck-dark/10" />
|
||||
<div className="h-2.5 w-1/3 rounded bg-duck-dark/10" />
|
||||
</div>
|
||||
) : e.status === 'error' ? (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-destructive">
|
||||
{t('videoDownload.couldNotFetch')}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">{e.error || e.url}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="truncate text-sm font-medium text-duck-dark" title={e.title}>
|
||||
{e.title || e.url}
|
||||
</p>
|
||||
<p className="truncate text-xs text-duck-dark/50">
|
||||
{[e.uploader, fmtDuration(e.duration)].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
{e.dl === 'error' && <p className="truncate text-xs text-destructive">{e.dlError}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{e.status === 'ready' && (
|
||||
<div className="shrink-0">
|
||||
<div className="flex justify-end">
|
||||
{e.dl === 'done' ? (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-duck-teal">
|
||||
<Check size={14} /> {t('videoDownload.saved')}
|
||||
|
||||
+272
-5
@@ -10,6 +10,7 @@ import { folderFromPathname, folderHref, relativeToFolder, withoutViewerParams }
|
||||
import { useTasks, runsWithoutPrompting, type TaskSummary } from '../useTasks';
|
||||
import { useAgents, type AgentSummary } from '../useAgents';
|
||||
import { useUserState } from 'state/useUserState';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { runTaskNow } from './components/runTaskNow';
|
||||
@@ -24,7 +25,30 @@ import { useTranslation } from '../../../locales';
|
||||
*/
|
||||
export const FILES_PATH_PARAM = 'path';
|
||||
|
||||
export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPath = false, panelId?: string) => {
|
||||
/**
|
||||
* `pathParam` — keep the current folder in a QUERY PARAM, on whatever route we are already on.
|
||||
*
|
||||
* This is not `urlPath` and must not be confused with it. `urlPath` owns the URL's PATHNAME: it reads
|
||||
* the folder out of `location.pathname` and navigates to `/files/<folder>`. That is correct for the
|
||||
* Files screen, which IS that route, and catastrophic inside a panel — a plugin passing `urlPath`
|
||||
* navigated the whole app to /files the moment anybody opened a folder.
|
||||
*
|
||||
* So an embedder gets its own mechanism: a named search param, written with `replace` so browsing does
|
||||
* not fill the back stack, and no pathname involved at all. A reload lands where you were and the link
|
||||
* can be sent to somebody, which is what was actually wanted.
|
||||
*/
|
||||
/** What a picker is handed when somebody opens a file in pick mode. */
|
||||
export type PickedFile = { name: string; path: string; type: string };
|
||||
|
||||
export const useFileBrowserApp = (
|
||||
basePath: string,
|
||||
rootOverride?: string,
|
||||
urlPath = false,
|
||||
panelId?: string,
|
||||
pathParam?: string,
|
||||
/** Set to turn this into a chooser: opening a file calls this instead of a viewer. */
|
||||
onPickFile?: (file: PickedFile) => void,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
@@ -64,14 +88,55 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
// visit still opens at home, because a URL with no `path` means the base. Either way the main
|
||||
// (unscoped) browser mirrors its folder into files/currentPath so the Create Dashboard flow can
|
||||
// default a new dashboard's cwd to it.
|
||||
const currentPath = urlPath ? folderFromPathname(location.pathname) : localPath;
|
||||
/*
|
||||
Three sources, mutually exclusive by construction: the pathname for /files, a named query param for
|
||||
an embedded browser, and component state for an ordinary panel.
|
||||
|
||||
── The param is RELATIVE to the base, and that is not only for tidiness ──
|
||||
|
||||
An absolute path put the account's own email in every link — `?media=/officerdev/data/
|
||||
someone@gmail.com/...` — so a URL sent to a colleague carried the owner's address with it. Relative,
|
||||
the same folder is `?media=ABOUT+US`: shorter, and it says nothing about the machine or the account.
|
||||
|
||||
It is also what makes the jail hold by construction rather than by comparison. The value is JOINED
|
||||
to `basePath`, so there is no "outside" to check for — only `..` could climb out, and a segment of
|
||||
`..` is refused outright.
|
||||
*/
|
||||
const paramRelative = pathParam ? searchParams.get(pathParam) : null;
|
||||
const paramPath =
|
||||
paramRelative && !paramRelative.split('/').includes('..')
|
||||
? `${basePath.replace(/\/$/, '')}/${paramRelative.replace(/^\/+/, '')}`
|
||||
: null;
|
||||
const currentPath = urlPath ? folderFromPathname(location.pathname) : pathParam ? (paramPath ?? basePath) : localPath;
|
||||
const setCurrentPath = (path: string) => {
|
||||
// Leaving a folder closes what was open in it. A pane shows a file from the folder you were in, so
|
||||
// carrying it into the next one leaves the two disagreeing — the listing says one place, the pane
|
||||
// another. Only the OVERLAY params go; anything else in the query (global UI state) is left alone,
|
||||
// which is the distinction a wholesale reset kept getting wrong.
|
||||
if (urlPath) navigate({ pathname: folderHref(path), search: withoutViewerParams(location.search) });
|
||||
else {
|
||||
else if (pathParam) {
|
||||
/*
|
||||
PUSHED, not replaced — so Back and Forward move between folders.
|
||||
|
||||
This was `replace` on the argument that browsing is not navigation worth a history entry. That
|
||||
was wrong, and wrong in a way the URL itself gives away: the moment a folder is addressable, it
|
||||
is a place, and Back meaning "the folder I was just in" is what anybody expects of a place. It
|
||||
also matches `/files`, which pushes — an embedded browser behaving differently from the full one
|
||||
is a difference nobody can see a reason for.
|
||||
|
||||
Reading is unchanged: `currentPath` is derived from the param, so a Back that rewrites the query
|
||||
moves the browser with it and needs nothing here.
|
||||
*/
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(withoutViewerParams(prev.toString()));
|
||||
// The base folder is the ABSENCE of the param, so a link to the top of an embedded browser is
|
||||
// the plain screen URL and nothing accumulates for having gone nowhere.
|
||||
const relative = path.startsWith(basePath) ? path.slice(basePath.replace(/\/$/, '').length + 1) : '';
|
||||
if (!relative) next.delete(pathParam);
|
||||
else next.set(pathParam, relative);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setLocalPath(path);
|
||||
// Written here rather than in an effect on `currentPath`, so it records a NAVIGATION and not the
|
||||
// initial render. An effect would fire once on mount with the restored value and persist it back,
|
||||
@@ -133,7 +198,25 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
const { togglePin, isPinned } = usePinnedFiles();
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [clipboard, setClipboard] = useState<ClipboardState>(null);
|
||||
/*
|
||||
The clipboard lives on the SERVER, not in this component.
|
||||
|
||||
── Why ──
|
||||
|
||||
It was `useState`, which meant it existed in one tab and died with it. Copying in one window and
|
||||
pasting in another — the obvious way to move a file between two folders you can both see — did
|
||||
nothing, and a reload lost a pending cut with no sign that it had.
|
||||
|
||||
`user_state` is a per-user JSONB row that already exists for exactly this kind of thing, and
|
||||
`useUserState` already reads and writes it through React Query with an optimistic local update. So
|
||||
a clipboard is one key in it and there is no new table, route or socket.
|
||||
|
||||
Per user rather than per browser, deliberately: cutting on the desktop and pasting on the phone is
|
||||
the same gesture across two screens, and there is no reading of "my clipboard" where the two should
|
||||
disagree.
|
||||
*/
|
||||
const queryClient = useQueryClient();
|
||||
const [clipboard, setClipboard] = useUserState<ClipboardState>('fileClipboard', null);
|
||||
const [renamingName, setRenamingName] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
|
||||
@@ -164,6 +247,10 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
filesRef.current = files;
|
||||
const currentPathRef = useRef(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
// Read inside `refresh`, which is not re-created per render and would otherwise close over a
|
||||
// stale list the first time it ran.
|
||||
const entriesRef = useRef(entries);
|
||||
entriesRef.current = entries;
|
||||
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
@@ -171,8 +258,29 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
|
||||
const firstListing = useRef(true);
|
||||
|
||||
/**
|
||||
* The folder the entries currently on screen belong to.
|
||||
*
|
||||
* What separates "re-read this folder" from "go to a different one", which look identical from
|
||||
* inside `refresh` and need opposite treatment — see below.
|
||||
*/
|
||||
const shownPath = useRef<string | null>(null);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
/*
|
||||
Blank the list only when there is nothing true to show.
|
||||
|
||||
`loading` swaps the whole grid for a spinner, which is right when arriving somewhere new and
|
||||
wrong when re-reading a folder already on screen: every refresh — after a paste, after a delete,
|
||||
and now every time the window comes forward — made the contents vanish and come back. On a
|
||||
folder of a hundred files that is a flash and a lost scroll position, for information that was
|
||||
already correct.
|
||||
|
||||
So the spinner is for a folder we cannot yet show: nothing listed, or a different folder than the
|
||||
one listed. Re-reading the same folder leaves what is there until better information arrives, and
|
||||
the only visible change is whatever actually changed.
|
||||
*/
|
||||
if (entriesRef.current.length === 0 || shownPath.current !== currentPathRef.current) setLoading(true);
|
||||
try {
|
||||
const data = await filesRef.current.listDir(currentPathRef.current);
|
||||
if (data.reset) {
|
||||
@@ -180,6 +288,7 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
return;
|
||||
}
|
||||
setEntries(data.entries);
|
||||
shownPath.current = currentPathRef.current;
|
||||
if (data.rootDir) setRootDir(data.rootDir);
|
||||
} catch (err: any) {
|
||||
console.error('[FileBrowser] refresh error:', err);
|
||||
@@ -243,6 +352,50 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
/*
|
||||
Pull the clipboard again whenever this window comes forward.
|
||||
|
||||
`useUserState` holds USER_STATE with `staleTime: Infinity` — right for a dock layout, wrong for
|
||||
something another window can change while this one is idle. Without this, window A cuts, window B
|
||||
still shows nothing to paste, and the feature reads as broken in exactly the case it was built for.
|
||||
|
||||
Focus rather than a poll or a socket, and that is a judgement rather than a shortcut: pasting into
|
||||
the other window means focusing the other window first, so the refresh happens at precisely the
|
||||
moment it is needed and never while nobody is looking. A socket would be live for a case that does
|
||||
not arise — two visible windows dragging between each other already carry the paths in the drag
|
||||
itself.
|
||||
|
||||
`visibilitychange` as well as `focus`, because a background tab brought forward by switching tabs
|
||||
fires only the second one in some browsers.
|
||||
*/
|
||||
/*
|
||||
...and the FOLDER as well as the clipboard.
|
||||
|
||||
Cutting in one window and pasting in another moves the file on disk, and the window it left went on
|
||||
listing it until somebody pressed refresh. The clipboard was the obvious half of "these two windows
|
||||
disagree"; the listing is the half that matters more, because a file that is not there any more is
|
||||
a lie rather than a stale label.
|
||||
|
||||
Held in a ref because `refreshRef.current` is rebuilt on every render — putting it in the
|
||||
dependency array would tear down and re-add both listeners several times a second.
|
||||
*/
|
||||
const refreshRef = useRef(refresh);
|
||||
refreshRef.current = refresh;
|
||||
|
||||
useEffect(() => {
|
||||
const onForeground = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
void queryClient.invalidateQueries({ queryKey: ['USER_STATE'] });
|
||||
void refreshRef.current();
|
||||
};
|
||||
window.addEventListener('focus', onForeground);
|
||||
document.addEventListener('visibilitychange', onForeground);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onForeground);
|
||||
document.removeEventListener('visibilitychange', onForeground);
|
||||
};
|
||||
}, [queryClient]);
|
||||
|
||||
// Clipboard image paste
|
||||
useEffect(() => {
|
||||
const handler = (ev: ClipboardEvent) => {
|
||||
@@ -416,6 +569,21 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
setCurrentPath(next);
|
||||
return;
|
||||
}
|
||||
/*
|
||||
PICK MODE.
|
||||
|
||||
When a caller is using this browser to CHOOSE a file rather than to work in a folder, opening a
|
||||
file means handing it back — not opening a viewer the caller would then have to close. Folders
|
||||
still navigate above, because choosing a file requires getting to it.
|
||||
|
||||
A prop rather than a second browser: a picker built separately would be a listing, a breadcrumb,
|
||||
a sort, thumbnails and keyboard handling reimplemented, all of it drifting from the real one.
|
||||
This is the same component with one branch.
|
||||
*/
|
||||
if (onPickFile) {
|
||||
onPickFile({ name: entry.name, path: entryPath(entry.name), type: entry.type });
|
||||
return;
|
||||
}
|
||||
// An archive extracts rather than opening a pane. The viewer has nothing to show for one — it renders
|
||||
// a placeholder reading "Archive file" and the name you can already see in the listing — so a pane
|
||||
// was a step on the way to the only thing anyone opens a zip for. Extract resolves collisions, so a
|
||||
@@ -911,6 +1079,78 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
}
|
||||
};
|
||||
|
||||
/*
|
||||
Dragging rows between windows.
|
||||
|
||||
── The MIME type is the whole trick ──
|
||||
|
||||
A custom type on the DataTransfer survives between tabs and windows of the SAME browser, which is
|
||||
the case somebody actually has: two Officer windows side by side, one folder in each. It does not
|
||||
survive between different browsers, and it cannot exchange with Finder — dragging out to the
|
||||
desktop needs a Chrome-only API and only works outward.
|
||||
|
||||
`Files` is deliberately untouched. That is the type the OS puts on a real file drag, and the
|
||||
existing upload path keys off it; a drag from inside carries only ours, so the two never meet.
|
||||
|
||||
── It also writes the clipboard ──
|
||||
|
||||
Belt and braces, and cheap now that the clipboard is per-user server state: if a drop lands
|
||||
somewhere that cannot read the payload, the paths are still held and a paste finishes the job. The
|
||||
drag becomes a gesture over the same mechanism rather than a second one beside it.
|
||||
*/
|
||||
const DRAG_TYPE = 'application/x-officer-files';
|
||||
|
||||
const handleEntryDragStart = (ev: React.DragEvent, entry: DirEntry) => {
|
||||
// Dragging one of several selected rows takes the whole selection; dragging an unselected row
|
||||
// takes just that one, which is what every file manager does and what a hand expects.
|
||||
const paths = selected.has(entry.name)
|
||||
? [...selected].map((name) => entryPath(name))
|
||||
: [entryPath(entry.name)];
|
||||
|
||||
ev.dataTransfer.setData(DRAG_TYPE, JSON.stringify(paths));
|
||||
// `move` so the cursor shows what will happen. The drop decides for real; this is the hint.
|
||||
ev.dataTransfer.effectAllowed = 'move';
|
||||
setClipboard({ paths, mode: 'cut' });
|
||||
};
|
||||
|
||||
/** Paths from a drag that started in an Officer window, or null if this is something else. */
|
||||
const draggedPaths = (ev: React.DragEvent): string[] | null => {
|
||||
if (!ev.dataTransfer.types.includes(DRAG_TYPE)) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(ev.dataTransfer.getData(DRAG_TYPE));
|
||||
return Array.isArray(parsed) && parsed.every((p) => typeof p === 'string') ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop onto a folder row — move into it.
|
||||
*
|
||||
* Refuses a folder being dropped into itself or into its own child, which the server would reject
|
||||
* anyway but with an error rather than a shrug. Comparing with a trailing separator so `/a/bc` is
|
||||
* not read as living inside `/a/b`.
|
||||
*/
|
||||
const handleEntryDrop = async (ev: React.DragEvent, entry: DirEntry) => {
|
||||
const paths = draggedPaths(ev);
|
||||
if (!paths || entry.type !== 'directory') return;
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const destination = entryPath(entry.name);
|
||||
const into = paths.filter((path) => destination !== path && !destination.startsWith(`${path}/`));
|
||||
if (into.length === 0) return;
|
||||
|
||||
try {
|
||||
await files.move(into, destination);
|
||||
setClipboard(null);
|
||||
await refresh();
|
||||
toast.success(t('files.pastedItems', { count: into.length }));
|
||||
} catch {
|
||||
toast.error(t('files.pasteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (ev: React.DragEvent) => {
|
||||
ev.preventDefault();
|
||||
dragCounter.current++;
|
||||
@@ -936,6 +1176,28 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
dragCounter.current = 0;
|
||||
setDragging(false);
|
||||
|
||||
/*
|
||||
A drag that began in an Officer window, dropped on this folder's background.
|
||||
|
||||
Checked before the upload path, because a drag from inside carries no `Files` and would
|
||||
otherwise fall through to "read the dropped files", find none, and do nothing at all.
|
||||
*/
|
||||
const dragged = draggedPaths(ev);
|
||||
if (dragged) {
|
||||
const into = dragged.filter((path) => currentPath !== path && !currentPath.startsWith(`${path}/`));
|
||||
// Dropping a folder into the folder it already lives in. Nothing to do, and nothing to say.
|
||||
if (into.length === 0) return;
|
||||
try {
|
||||
await files.move(into, currentPath);
|
||||
setClipboard(null);
|
||||
await refresh();
|
||||
toast.success(t('files.pastedItems', { count: into.length }));
|
||||
} catch {
|
||||
toast.error(t('files.pasteFailed'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const items = ev.dataTransfer.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
@@ -1029,7 +1291,12 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
// Selection
|
||||
selected,
|
||||
setSelected,
|
||||
handleEntryDragStart,
|
||||
handleEntryDrop,
|
||||
clipboard,
|
||||
// Exposed so the toolbar can let go of what is held. It is per-user state now, so a stale
|
||||
// clipboard is something a person has to be able to clear rather than wait out.
|
||||
setClipboard,
|
||||
renamingName,
|
||||
setRenamingName,
|
||||
// Search
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Actions a PLUGIN contributes to the file browser's context menu.
|
||||
*
|
||||
* ── Why this exists beside tasks ──
|
||||
*
|
||||
* A task already reaches that menu, and for "do this to these files" it is the right thing: it runs a
|
||||
* script as the owner, reports through the job system, and needs no frontend at all.
|
||||
*
|
||||
* It is the wrong shape for anything that continues in the interface. "Create Gallery" from a folder is
|
||||
* not a job to watch — it is a name to type and a screen to land on, and expressing that as a script
|
||||
* would mean a job whose only output is somewhere to go next.
|
||||
*
|
||||
* So: a plugin exports `fileActions` from `web/file-actions.ts`, the generated plugin module collects
|
||||
* them, and the menu renders the ones that apply. Presence on disk is the declaration, exactly as
|
||||
* `panels.ts` and `layout.ts` already are.
|
||||
*
|
||||
* ── They run in the BROWSER ──
|
||||
*
|
||||
* Which is the whole difference. An action gets the selection and does what a panel would do — open a
|
||||
* dialog, call its own plugin's API, navigate. It has no privileges of its own: everything it calls
|
||||
* goes through the same permission gate as every other request that page makes.
|
||||
*/
|
||||
|
||||
/** One entry the action was invoked on. */
|
||||
export type FileActionEntry = {
|
||||
name: string;
|
||||
/** Absolute within the browser's root, as the browser addresses it. */
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
};
|
||||
|
||||
export type FileActionContext = {
|
||||
/** The folder currently open, so an action can act on "here" rather than on a selection. */
|
||||
currentPath: string;
|
||||
/** Re-read the listing. For an action that creates or removes something on disk. */
|
||||
refresh: () => void;
|
||||
/**
|
||||
* An authenticated client for Officer's API, rooted at `/api`.
|
||||
*
|
||||
* Handed over rather than left to the action, because there is no way for it to build one: `run` is
|
||||
* called from an event handler outside React, so it cannot use `useClient`, and a bare `fetch` gets a
|
||||
* 401 — this API authenticates with a bearer token, not a cookie, and only the hook holds it.
|
||||
*/
|
||||
client: {
|
||||
get: <T>(url: string) => Promise<T>;
|
||||
post: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
put: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
delete: <T>(url: string, payload?: unknown) => Promise<T>;
|
||||
};
|
||||
};
|
||||
|
||||
export type FileAction = {
|
||||
/** Unique across every plugin. Prefixed with the plugin name by convention, like panel keys. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** A lucide icon component, as the rest of the menu uses. */
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
/**
|
||||
* Whether this action makes sense for what was selected.
|
||||
*
|
||||
* Given EVERY entry the action would receive, so an action that only handles images can refuse a
|
||||
* mixed selection rather than half-working. Returning false hides it — a menu of greyed-out items
|
||||
* nobody can explain is worse than a shorter menu.
|
||||
*/
|
||||
appliesTo: (entries: FileActionEntry[]) => boolean;
|
||||
run: (entries: FileActionEntry[], context: FileActionContext) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The registry, filled by the app at startup.
|
||||
*
|
||||
* ── Why injection rather than an import ──
|
||||
*
|
||||
* The generated plugin module lives in the APP (`src/apps/officer-web/Plugins.gen.tsx`) and this is a
|
||||
* workspace package the app depends on. Importing it from here would point the dependency backwards and
|
||||
* make this package unusable by anything that is not Officer's own frontend.
|
||||
*
|
||||
* So the package owns the list and the app hands it over — one call, at startup, before anything
|
||||
* renders a file browser.
|
||||
*/
|
||||
let registered: FileAction[] = [];
|
||||
|
||||
export const registerFileActions = (actions: FileAction[]): void => {
|
||||
registered = actions;
|
||||
};
|
||||
|
||||
/**
|
||||
* The actions that apply to this selection, in registration order.
|
||||
*
|
||||
* `entries` is what the action would receive: the whole selection when the right-clicked item is part
|
||||
* of a multiple selection, and just that item otherwise. That is what every file manager does, and the
|
||||
* alternative — acting on a selection the click was not part of — is the surprising one.
|
||||
*/
|
||||
export const fileActionsFor = (entries: FileActionEntry[]): FileAction[] =>
|
||||
registered.filter((action) => {
|
||||
try {
|
||||
return action.appliesTo(entries);
|
||||
} catch {
|
||||
// A plugin's predicate that throws hides its own action rather than breaking the menu for
|
||||
// everything else on it.
|
||||
return false;
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export { useTasks, type TaskSummary } from './useTasks';
|
||||
export { useRecentFiles } from './useRecentFiles';
|
||||
export { usePinnedFiles } from './usePinnedFiles';
|
||||
export { FileBrowserApp, FileBrowserPanelWrapper } from './FileBrowserApp';
|
||||
export type { PickedFile } from './FileBrowserApp/useFileBrowserApp';
|
||||
export { FileBrowserWidget } from './FileBrowserWidget';
|
||||
export { TaskRunnerModal } from './FileBrowserApp/components/TaskRunnerModal';
|
||||
|
||||
@@ -27,3 +28,5 @@ export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
export type { FileAction, FileActionEntry, FileActionContext } from './file-actions';
|
||||
export { registerFileActions, fileActionsFor } from './file-actions';
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useFilesAPI, type DirEntry } from '../../hooks/useFilesAPI';
|
||||
import { getFileType, getThumbUrl, thumbVersion } from './file-types';
|
||||
import { useFileViewer } from './FileViewerContext';
|
||||
|
||||
/**
|
||||
* Move between the files in a folder without going back to the listing.
|
||||
*
|
||||
* ── Which files ──
|
||||
*
|
||||
* Everything in the same folder the viewer can SHOW — images, video, audio, pdf, markdown, code, text.
|
||||
* Not directories and not archives: opening one is an extraction rather than a view, so putting them in
|
||||
* a strip of things you step through would offer a step that does something else entirely.
|
||||
*
|
||||
* ── It lists the folder itself ──
|
||||
*
|
||||
* The viewer is given one path and nothing else, and it is rendered by two different screens that reach
|
||||
* it in two different ways. Listing the folder here means neither has to pass a collection, and the
|
||||
* strip is right the moment a file is opened from anywhere — a link somebody sent, a search result, or
|
||||
* the grid.
|
||||
*
|
||||
* ── The thumbnails are the file browser's ──
|
||||
*
|
||||
* Same `/thumb` route, same cache, same size the grid asks for. So a folder already browsed has a warm
|
||||
* strip, and a HEIC in it works exactly where it works elsewhere rather than being a second thing to
|
||||
* teach about formats.
|
||||
*/
|
||||
|
||||
const STEPPABLE = new Set(['image', 'video', 'audio', 'pdf', 'markdown', 'code', 'text']);
|
||||
|
||||
export const FileCarousel = ({ onClose }: { onClose?: () => void }) => {
|
||||
const { filePath, root, onNavigate } = useFileViewer();
|
||||
const files = useFilesAPI(root || 'home');
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
|
||||
const folder = filePath.slice(0, filePath.lastIndexOf('/')) || '/';
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void files
|
||||
.listDir(folder)
|
||||
.then((data) => {
|
||||
if (live) setEntries(data.entries ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
// A folder that cannot be listed simply gets no strip. The file itself is already open and
|
||||
// readable; failing loudly here would break a working view over its navigation.
|
||||
if (live) setEntries([]);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// `folder` only: re-listing on every file change would refetch the same directory for each step.
|
||||
}, [folder]);
|
||||
|
||||
const siblings = useMemo(
|
||||
() =>
|
||||
entries
|
||||
.filter((e) => e.type === 'file' && STEPPABLE.has(getFileType(e.name)))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const index = siblings.findIndex((e) => `${folder === '/' ? '' : folder}/${e.name}` === filePath);
|
||||
const go = useCallback(
|
||||
(delta: number) => {
|
||||
if (index === -1 || !onNavigate) return;
|
||||
const next = siblings[index + delta];
|
||||
if (next) onNavigate(`${folder === '/' ? '' : folder}/${next.name}`);
|
||||
},
|
||||
[index, siblings, folder, onNavigate],
|
||||
);
|
||||
|
||||
/*
|
||||
Keyboard, on the window.
|
||||
|
||||
Ignored while somebody is typing — the viewer can be EDITING a file, and stealing arrow keys from a
|
||||
text area would make the editor unusable. `isContentEditable` catches the rich cases that are not
|
||||
inputs.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA' || target?.isContentEditable) return;
|
||||
|
||||
if (event.key === 'Escape' && onClose) {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
go(-1);
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
go(1);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [go, onClose]);
|
||||
|
||||
// One file in the folder is not a carousel. The strip would be a row containing the thing already on
|
||||
// screen, and two arrows that do nothing.
|
||||
if (!onNavigate || siblings.length < 2) return null;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2 border-t border-duck-dark/10 bg-background/60 px-2 py-2 dark:border-foreground/10">
|
||||
<Caret side="left" disabled={index <= 0} onClick={() => go(-1)} />
|
||||
|
||||
<div className="flex min-w-0 flex-1 gap-1.5 overflow-x-auto">
|
||||
{siblings.map((entry, i) => {
|
||||
const path = `${folder === '/' ? '' : folder}/${entry.name}`;
|
||||
const active = i === index;
|
||||
return (
|
||||
<button
|
||||
key={entry.name}
|
||||
type="button"
|
||||
onClick={() => onNavigate(path)}
|
||||
title={entry.name}
|
||||
/* `ref` scrolls the active one into view as you step, so the strip follows rather than
|
||||
being something you have to drag along behind you. */
|
||||
ref={(el) => {
|
||||
if (active) el?.scrollIntoView({ block: 'nearest', inline: 'center' });
|
||||
}}
|
||||
className={`h-12 w-16 shrink-0 overflow-hidden rounded border-2 transition-colors ${
|
||||
active ? 'border-duck-teal' : 'border-transparent opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
{entry.thumb ? (
|
||||
<img
|
||||
src={getThumbUrl(path, root, 256, thumbVersion(entry))}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="size-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex size-full items-center justify-center px-1 text-[9px] leading-tight break-all text-duck-dark/60 dark:text-foreground/60">
|
||||
{entry.name.slice(0, 18)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Caret side="right" disabled={index === -1 || index >= siblings.length - 1} onClick={() => go(1)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Caret = ({ side, disabled, onClick }: { side: 'left' | 'right'; disabled: boolean; onClick: () => void }) => {
|
||||
const Icon = side === 'left' ? ChevronLeft : ChevronRight;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={side === 'left' ? 'Previous (←)' : 'Next (→)'}
|
||||
className="shrink-0 rounded p-1 text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-25 dark:text-foreground/60 dark:hover:bg-foreground/5"
|
||||
>
|
||||
<Icon className="size-5" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -5,6 +5,14 @@ export type FileViewerContextValue = {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
root: string;
|
||||
/**
|
||||
* Show a different file, without leaving the viewer. Absent when the host cannot express it.
|
||||
*
|
||||
* Each host writes it differently — the Files screen puts a name in `?view=`, an embedded panel puts a
|
||||
* path — so the viewer asks rather than deciding, and a host that offers nothing simply gets no
|
||||
* carousel.
|
||||
*/
|
||||
onNavigate?: (filePath: string) => void;
|
||||
fileType: FileType;
|
||||
content: string | null;
|
||||
loading: boolean;
|
||||
|
||||
@@ -13,6 +13,8 @@ type FileViewerProviderProps = {
|
||||
root?: string;
|
||||
content?: string;
|
||||
onOpenFile?: (filePath: string, root: string) => void;
|
||||
/** Show a different file in the same viewer — what the carousel calls. */
|
||||
onNavigate?: (filePath: string) => void;
|
||||
onReplaceView?: (viewPath: string, viewRoot: string, ephemeralPath: string, ephemeralRoot: string) => void;
|
||||
autoPlay?: boolean;
|
||||
children: ReactNode;
|
||||
@@ -45,6 +47,7 @@ export const FileViewerProvider = ({
|
||||
root = 'home',
|
||||
content: directContent,
|
||||
onOpenFile,
|
||||
onNavigate,
|
||||
onReplaceView,
|
||||
autoPlay = false,
|
||||
children,
|
||||
@@ -171,6 +174,7 @@ export const FileViewerProvider = ({
|
||||
<FileViewerContext
|
||||
value={{
|
||||
filePath,
|
||||
onNavigate,
|
||||
fileName,
|
||||
root,
|
||||
fileType,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { X } from 'lucide-react';
|
||||
import { FileViewerProvider } from './FileViewerProvider';
|
||||
import { FileViewerHeader } from './FileViewerHeader';
|
||||
import { FileViewerBody } from './FileViewerBody';
|
||||
import { FileCarousel } from './FileCarousel';
|
||||
|
||||
type FileViewerViewProps = {
|
||||
filePath: string;
|
||||
@@ -9,10 +10,12 @@ type FileViewerViewProps = {
|
||||
root?: string;
|
||||
content?: string;
|
||||
onClose?: () => void;
|
||||
/** Enables the carousel. Without it the viewer shows one file and no strip. */
|
||||
onNavigate?: (filePath: string) => void;
|
||||
};
|
||||
|
||||
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => (
|
||||
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
|
||||
export const FileViewerView = ({ filePath, fileName, root, content, onClose, onNavigate }: FileViewerViewProps) => (
|
||||
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content} onNavigate={onNavigate}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
|
||||
<FileViewerHeader />
|
||||
@@ -28,6 +31,11 @@ export const FileViewerView = ({ filePath, fileName, root, content, onClose }: F
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileViewerBody />
|
||||
</div>
|
||||
|
||||
{/* Below the content, outside the scrolling area — a strip that scrolled away with a long
|
||||
markdown file would be a control you have to go looking for. Renders nothing when the folder
|
||||
holds one viewable file, or when the host gave no `onNavigate`. */}
|
||||
<FileCarousel onClose={onClose} />
|
||||
</div>
|
||||
</FileViewerProvider>
|
||||
);
|
||||
|
||||
@@ -160,6 +160,36 @@ export function getRawUrl(filePath: string, root?: string, audioTrack?: number):
|
||||
return `${API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}${audioParam}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file browser's own thumbnail, at the size its grid asks for.
|
||||
*
|
||||
* Same route, same cache, same size — so a folder already browsed has a warm carousel strip, and a
|
||||
* format that needs a plugin to decode works here exactly where it works there.
|
||||
*
|
||||
* `version` is what makes the year-long `immutable` on that response honest. The SERVER's cache key has
|
||||
* always held the file's mtime and size; this URL held only the path, so a browser that had once drawn
|
||||
* a thumbnail for a name kept drawing it for a year however the file behind that name changed. Upload,
|
||||
* rename something else into the name, replace a photograph — the picture on screen stayed. Pass
|
||||
* `mtime-size` and a changed file is simply a different URL. The server ignores the parameter; it stats
|
||||
* the file regardless.
|
||||
*/
|
||||
export function getThumbUrl(filePath: string, root?: string, size = 256, version?: string): string {
|
||||
const headers = getHeaders();
|
||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||
const versionParam = version ? `&v=${encodeURIComponent(version)}` : '';
|
||||
return `${API_URL}/file-browser/thumb?path=${encodeURIComponent(filePath)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}${versionParam}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* What identifies this VERSION of a file, for a cache key — the same two facts the server hashes.
|
||||
*
|
||||
* One place, because the grid and the carousel strip must agree: two spellings would mean two URLs for
|
||||
* one file, which is two renders and two cache entries for no reason.
|
||||
*/
|
||||
export const thumbVersion = (entry: { modifiedAt?: number; size?: number }): string | undefined =>
|
||||
Number.isFinite(entry.modifiedAt) && Number.isFinite(entry.size) ? `${entry.modifiedAt}-${entry.size}` : undefined;
|
||||
|
||||
export function getAudioTracksUrl(filePath: string, root?: string): string {
|
||||
const headers = getHeaders();
|
||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
export { FileViewerView } from './FileViewerView';
|
||||
export { FileCarousel } from './FileCarousel';
|
||||
export { FileViewerProvider } from './FileViewerProvider';
|
||||
export { FileViewerHeader } from './FileViewerHeader';
|
||||
export { FileViewerBody } from './FileViewerBody';
|
||||
export { useFileViewer } from './FileViewerContext';
|
||||
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types';
|
||||
export { getFileType, getLang, getExt, getArchiveBaseName, thumbVersion, ARCHIVE_EXTS, type FileType } from './file-types';
|
||||
|
||||
// No `appRegistryMetas` here on purpose. The file viewer is not a panel app you can put on a dashboard:
|
||||
// it is mounted as an ephemeral panel by `useFileViewerPanels`, which supplies `FileViewerBody`/`Header`
|
||||
|
||||
@@ -16,8 +16,22 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
/*
|
||||
WHICH src has loaded, not whether one has.
|
||||
|
||||
The first fix reset a boolean in an effect on `src`, and that has a race a cached image wins every
|
||||
time: the effect runs AFTER render, so the browser fires `onLoad` for an image already in cache
|
||||
before the effect gets to clear the flag — and then the effect clears it, and `onLoad` never comes
|
||||
again. The spinner stayed forever on exactly the files that were fastest.
|
||||
|
||||
Comparing the loaded src to the current one has no ordering at all. It is false the instant `src`
|
||||
changes, true the moment that src's own load fires, and there is no window in which the two can be
|
||||
written in the wrong order.
|
||||
*/
|
||||
const [loadedSrc, setLoadedSrc] = useState<string | null>(null);
|
||||
const [errorSrc, setErrorSrc] = useState<string | null>(null);
|
||||
const loaded = loadedSrc === src;
|
||||
const error = errorSrc === src;
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const dragStart = useRef({ x: 0, y: 0, ox: 0, oy: 0 });
|
||||
@@ -77,6 +91,30 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||
if (zoom <= 1) setOffset({ x: 0, y: 0 });
|
||||
}, [zoom]);
|
||||
|
||||
/*
|
||||
A NEW FILE IS A NEW LOAD, and everything about the last one goes.
|
||||
|
||||
`loaded` was set once and never cleared, which did not matter while a viewer showed one file for its
|
||||
whole life. With a carousel it is the bug you see: the flag stays true, the opacity gate stays open,
|
||||
and the browser goes on painting the PREVIOUS photograph until the next one has decoded. Stepping
|
||||
through a folder looked like the image lagged a beat behind the strip — the strip was right and the
|
||||
picture was stale.
|
||||
|
||||
Zoom, rotation and pan reset with it. They are a view of one image, and carrying 3x and 90 degrees
|
||||
onto the next photograph means arriving somewhere nobody asked to be, in the corner of a picture
|
||||
they have not seen yet.
|
||||
*/
|
||||
/*
|
||||
Zoom, rotation and pan are a view of ONE image, so they reset when the image changes. These stay in
|
||||
an effect because they have no race to lose: nothing else writes them during a load, and being one
|
||||
render late is invisible.
|
||||
*/
|
||||
useEffect(() => {
|
||||
setZoom(1);
|
||||
setRotation(0);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}, [src]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@@ -104,8 +142,8 @@ export const ImageRenderer = ({ src, fileName }: ImageRendererProps) => {
|
||||
<img
|
||||
src={src}
|
||||
alt={fileName}
|
||||
onLoad={() => setLoaded(true)}
|
||||
onError={() => setError(true)}
|
||||
onLoad={() => setLoadedSrc(src)}
|
||||
onError={() => setErrorSrc(src)}
|
||||
className="transition-transform duration-150 select-none"
|
||||
draggable={false}
|
||||
style={{
|
||||
|
||||
@@ -534,9 +534,11 @@ export const PluginDetail = () => {
|
||||
how you teach someone to stop pressing it. */}
|
||||
{plugin.updateable && (
|
||||
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
|
||||
{plugin.releaseReason === 'new-release' && plugin.newestTag
|
||||
? `Update to ${plugin.newestTag}`
|
||||
: 'Update'}
|
||||
{/* Named whenever a release is known, not only on `new-release`. An untagged checkout
|
||||
reports `untagged`, and it used to lose the destination with it — the button said
|
||||
"Update" and the owner had to guess where it landed. The marketplace knows the newest
|
||||
release even for a checkout that has never fetched it. */}
|
||||
{plugin.newestTag ? `Update to ${plugin.newestTag}` : 'Update'}
|
||||
</Button>
|
||||
)}
|
||||
{/* Said out loud rather than shown as an absent button. "No Update button" and "up to date"
|
||||
|
||||
@@ -53,6 +53,15 @@ export function ViewerProvider({ children }: { children: ReactNode }) {
|
||||
fileName={fileName}
|
||||
onOpenFile={onOpenFile}
|
||||
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
|
||||
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
|
||||
screen's own convention — so the absolute path the carousel works in is converted back. */
|
||||
onNavigate={(next) =>
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
|
||||
return params;
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
@@ -83,6 +92,15 @@ export function EphemeralProvider({ children }: { children: ReactNode }) {
|
||||
root={ephemeralRoot}
|
||||
onOpenFile={onOpenFile}
|
||||
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
|
||||
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
|
||||
screen's own convention — so the absolute path the carousel works in is converted back. */
|
||||
onNavigate={(next) =>
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
|
||||
return params;
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
@@ -104,6 +122,15 @@ export function Ephemeral2Provider({ children }: { children: ReactNode }) {
|
||||
root={ephemeral2Root}
|
||||
autoPlay={ephemeral2Auto}
|
||||
onReplaceView={onReplaceView(setSearchParams, folderFromPathname(pathname))}
|
||||
/* The carousel's step. `?view=` here is a NAME within the folder the pathname names — this
|
||||
screen's own convention — so the absolute path the carousel works in is converted back. */
|
||||
onNavigate={(next) =>
|
||||
setSearchParams((prev) => {
|
||||
const params = new URLSearchParams(prev);
|
||||
params.set('view', relativeToFolder(folderFromPathname(pathname), next));
|
||||
return params;
|
||||
})
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
|
||||
@@ -205,6 +205,20 @@ export type DirEntry = {
|
||||
* response, which simply means no thumbnail — the file icon, exactly as before.
|
||||
*/
|
||||
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 = {
|
||||
|
||||
@@ -36,7 +36,7 @@ export { SessionList, ChatDetailPanel } from './apps/ChatHistory';
|
||||
export type { SelectedSession } from './apps/ChatHistory';
|
||||
// The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way.
|
||||
export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory';
|
||||
export { CodeEditorView } from './apps/CodeEditor';
|
||||
export { CodeEditorView, FileTree } from './apps/CodeEditor';
|
||||
|
||||
// Same for /system-monitor, where the param is the scope rather than a section.
|
||||
export { DEFAULT_MONITOR_SCOPE, monitorScopePath, isMonitorScope } from './apps/SystemMonitor/shared';
|
||||
@@ -59,6 +59,9 @@ export {
|
||||
useRecentFiles,
|
||||
usePinnedFiles,
|
||||
FileBrowserApp,
|
||||
registerFileActions,
|
||||
type FileAction,
|
||||
type PickedFile,
|
||||
FileBrowserPanelWrapper,
|
||||
FileBrowserWidget,
|
||||
TaskRunnerModal,
|
||||
@@ -66,6 +69,7 @@ export {
|
||||
export type { DirEntry, TaskSummary } from './apps/FileBrowser';
|
||||
export {
|
||||
FileViewerView,
|
||||
FileCarousel,
|
||||
FileViewerProvider,
|
||||
FileViewerHeader,
|
||||
FileViewerBody,
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Typ",
|
||||
"size": "Größe",
|
||||
"date": "Datum"
|
||||
}
|
||||
},
|
||||
"toCopy": "zu kopieren",
|
||||
"toMove": "zu verschieben",
|
||||
"clearClipboard": "Zwischenablage leeren"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stopp",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Type",
|
||||
"size": "Size",
|
||||
"date": "Date"
|
||||
}
|
||||
},
|
||||
"toCopy": "to copy",
|
||||
"toMove": "to move",
|
||||
"clearClipboard": "Clear clipboard"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stop",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Tipo",
|
||||
"size": "Tamaño",
|
||||
"date": "Fecha"
|
||||
}
|
||||
},
|
||||
"toCopy": "para copiar",
|
||||
"toMove": "para mover",
|
||||
"clearClipboard": "Vaciar portapapeles"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Detener",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Type",
|
||||
"size": "Taille",
|
||||
"date": "Date"
|
||||
}
|
||||
},
|
||||
"toCopy": "à copier",
|
||||
"toMove": "à déplacer",
|
||||
"clearClipboard": "Vider le presse-papiers"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Arrêter",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Tipo",
|
||||
"size": "Tamanho",
|
||||
"date": "Data"
|
||||
}
|
||||
},
|
||||
"toCopy": "a copiar",
|
||||
"toMove": "a mover",
|
||||
"clearClipboard": "Limpar área de transferência"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Parar",
|
||||
|
||||
@@ -542,7 +542,10 @@
|
||||
"type": "Typ",
|
||||
"size": "Storlek",
|
||||
"date": "Datum"
|
||||
}
|
||||
},
|
||||
"toCopy": "att kopiera",
|
||||
"toMove": "att flytta",
|
||||
"clearClipboard": "Töm urklipp"
|
||||
},
|
||||
"taskRunner": {
|
||||
"stop": "Stoppa",
|
||||
|
||||
@@ -2,6 +2,15 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
|
||||
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
|
||||
export { useUserState } from './useUserState';
|
||||
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 type { ModelOption } from './useModels';
|
||||
export { useAccessPolicy } from './useAccessPolicy';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import type { LayoutNode, LayoutPanel } from 'officerdev';
|
||||
import { __testing } from './useAuthoredLayout';
|
||||
|
||||
const { collectSizes, applySizes } = __testing;
|
||||
@@ -12,7 +12,7 @@ const { collectSizes, applySizes } = __testing;
|
||||
* way it can be wrong is silent, because a layout with the wrong numbers in it still renders.
|
||||
*/
|
||||
|
||||
const panel = (id: string, appType: string): LayoutNode => ({ type: 'panel', id, appType });
|
||||
const panel = (id: string, appType: string): LayoutPanel => ({ type: 'panel', id, appType });
|
||||
|
||||
const group = (id: string, children: { node: LayoutNode; size: number }[]): LayoutNode => ({
|
||||
type: 'group',
|
||||
@@ -127,3 +127,93 @@ describe('a repair that cannot stick must not be written', () => {
|
||||
expect(JSON.stringify(afterRepair)).toBe(JSON.stringify(current));
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Zoom, which was silently deleted for as long as only sizes were grafted.
|
||||
*
|
||||
* The failure was invisible rather than merely unsaved: the round trip produced a tree identical to the
|
||||
* one already held, so `setValue`'s no-write guard dropped it and the header went on reading 100%. These
|
||||
* tests are the round trip, at the level where it went wrong.
|
||||
*/
|
||||
describe('zoom survives the graft', () => {
|
||||
const { collectZooms, applyZooms, graft } = __testing;
|
||||
|
||||
/** The user has zoomed the chat panel, as `setZoom` would leave it. */
|
||||
const zoomed = group('root', [
|
||||
{
|
||||
node: group('lists', [
|
||||
{ node: panel('hosts', 'proxy-manager-hosts'), size: 62 },
|
||||
{ node: panel('certs', 'proxy-manager-certificates'), size: 38 },
|
||||
]),
|
||||
size: 66,
|
||||
},
|
||||
{ node: { ...panel('chat', 'proxy-manager-chat'), zoom: 1.2 }, size: 34 },
|
||||
]);
|
||||
|
||||
test('collects only the panels that carry one', () => {
|
||||
expect(collectZooms(zoomed)).toEqual({ chat: 1.2 });
|
||||
expect(collectZooms(source)).toEqual({});
|
||||
});
|
||||
|
||||
test('grafts onto the author structure, which never carries zoom itself', () => {
|
||||
const merged = graft(source, zoomed);
|
||||
expect(collectZooms(merged)).toEqual({ chat: 1.2 });
|
||||
});
|
||||
|
||||
test('a zoom change is a real difference, so the no-write guard lets it through', () => {
|
||||
const current = graft(source, source);
|
||||
const next = graft(source, zoomed);
|
||||
expect(JSON.stringify(next)).not.toBe(JSON.stringify(current));
|
||||
});
|
||||
|
||||
test('sizes and zoom graft together, neither eating the other', () => {
|
||||
const resized = group('root', [
|
||||
{
|
||||
node: group('lists', [
|
||||
{ node: panel('hosts', 'proxy-manager-hosts'), size: 20 },
|
||||
{ node: panel('certs', 'proxy-manager-certificates'), size: 80 },
|
||||
]),
|
||||
size: 66,
|
||||
},
|
||||
{ node: { ...panel('chat', 'proxy-manager-chat'), zoom: 0.8 }, size: 34 },
|
||||
]);
|
||||
const merged = graft(source, resized);
|
||||
expect(collectSizes(merged)).toEqual({ root: [66, 34], lists: [20, 80] });
|
||||
expect(collectZooms(merged)).toEqual({ chat: 0.8 });
|
||||
});
|
||||
|
||||
/**
|
||||
* `setZoom` deletes the field on reset rather than writing 1, so an absent id has to mean "no opinion"
|
||||
* — otherwise Reset zoom would be unreachable on a screen whose author shipped a zoom of their own.
|
||||
*/
|
||||
test('a reset drops back to whatever the author said', () => {
|
||||
const authored = group('root', [
|
||||
{ node: { ...panel('a', 'x'), zoom: 1.4 }, size: 50 },
|
||||
{ node: panel('b', 'y'), size: 50 },
|
||||
]);
|
||||
// Nothing stored: the author's 1.4 stands.
|
||||
expect(collectZooms(graft(authored, authored))).toEqual({ a: 1.4 });
|
||||
// The user overrides it.
|
||||
const overridden = group('root', [
|
||||
{ node: { ...panel('a', 'x'), zoom: 0.9 }, size: 50 },
|
||||
{ node: panel('b', 'y'), size: 50 },
|
||||
]);
|
||||
expect(collectZooms(graft(authored, overridden))).toEqual({ a: 0.9 });
|
||||
// The user resets — `setZoom` removed the field — and the author's value returns.
|
||||
const reset = group('root', [
|
||||
{ node: panel('a', 'x'), size: 50 },
|
||||
{ node: panel('b', 'y'), size: 50 },
|
||||
]);
|
||||
expect(collectZooms(graft(authored, reset))).toEqual({ a: 1.4 });
|
||||
});
|
||||
|
||||
test('a panel id that no longer exists is simply not applied', () => {
|
||||
const stale = group('root', [{ node: { ...panel('gone', 'x'), zoom: 2 }, size: 100 }]);
|
||||
expect(collectZooms(graft(source, stale))).toEqual({});
|
||||
});
|
||||
|
||||
test('applyZooms returns the node by reference when nothing changes', () => {
|
||||
const node = panel('a', 'x');
|
||||
expect(applyZooms(node, {})).toBe(node);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,9 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useDashboardState } from './useDashboardState';
|
||||
|
||||
/**
|
||||
* A layout whose STRUCTURE belongs to whoever wrote the screen, and whose SIZES belong to whoever uses it.
|
||||
* A layout whose STRUCTURE belongs to whoever wrote the screen, and whose VIEW belongs to whoever uses it.
|
||||
*
|
||||
* The view is sizes and zoom: the two things a `locked` screen still lets a person change.
|
||||
*
|
||||
* ── The bug this ends ──
|
||||
*
|
||||
@@ -20,18 +22,30 @@ import { useDashboardState } from './useDashboardState';
|
||||
* first panel and SAVED, so the corruption survived every reload, every fix, and every restart. The
|
||||
* row had to be deleted from Postgres, which is not a thing a user can do.
|
||||
*
|
||||
* ── The bug this ended a second time, and why zoom is here ──
|
||||
*
|
||||
* Sizes were the only thing grafted back, so `zoom` — which lives on the panel node, because the node is
|
||||
* the thing already persisted — was collected by nothing and rebuilt from `source` every render. The
|
||||
* result was a control that rendered, took the click, and discarded it: `setValue` re-derived a tree
|
||||
* identical to the one it already had, the no-write guard below dropped it as a no-op, and the header
|
||||
* went on reading 100%. Silent on every plugin screen, `/bots` and `/plugins`, and reported as "the zoom
|
||||
* buttons do nothing".
|
||||
*
|
||||
* The lesson is the general one: anything the user owns has to be named here, because the default for a
|
||||
* field this hook does not know about is not "passed through" — it is "deleted, quietly".
|
||||
*
|
||||
* ── The fix, and why it is a shape rather than a rule ──
|
||||
*
|
||||
* The structure comes from `source` on every render and is never read back from storage. Only sizes are
|
||||
* The structure comes from `source` on every render and is never read back from storage. Only the view is
|
||||
* persisted. So a stale structure cannot exist, a repair has nothing to write itself into, and a plugin
|
||||
* that changes its layout reaches everybody on the next load — with no version to bump, no migration, and
|
||||
* nothing for an author to remember.
|
||||
*
|
||||
* The user keeps the one thing that was theirs.
|
||||
* The user keeps the things that were theirs.
|
||||
*
|
||||
* ── What is stored ──
|
||||
*
|
||||
* `source` with the user's sizes applied — a valid layout, because the storage validates one on both the
|
||||
* `source` with the user's view applied — a valid layout, because the storage validates one on both the
|
||||
* route and a CHECK constraint, and because a value that is always freshly derived from source cannot rot.
|
||||
* Its structure is written and never read. That asymmetry is the whole design and is why it is safe.
|
||||
*
|
||||
@@ -43,6 +57,7 @@ import { useDashboardState } from './useDashboardState';
|
||||
*/
|
||||
|
||||
type Sizes = Record<string, number[]>;
|
||||
type Zooms = Record<string, number>;
|
||||
|
||||
const isGroup = (node: LayoutNode): node is LayoutGroup => node.type === 'group';
|
||||
|
||||
@@ -54,6 +69,22 @@ function collectSizes(node: LayoutNode, into: Sizes = {}): Sizes {
|
||||
return into;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every panel's zoom, by panel id — and only where one is actually set.
|
||||
*
|
||||
* `setZoom` DELETES the field on a reset to 100% rather than writing 1, so an absent id means "the user
|
||||
* has no opinion" and the author's value stands. That is what makes Reset zoom reachable on a screen
|
||||
* whose author shipped a zoom of their own.
|
||||
*/
|
||||
function collectZooms(node: LayoutNode, into: Zooms = {}): Zooms {
|
||||
if (!isGroup(node)) {
|
||||
if (node.zoom !== undefined) into[node.id] = node.zoom;
|
||||
return into;
|
||||
}
|
||||
for (const child of node.children) collectZooms(child.node, into);
|
||||
return into;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put saved sizes back onto the author's structure.
|
||||
*
|
||||
@@ -78,22 +109,39 @@ function applySizes(node: LayoutNode, sizes: Sizes): LayoutNode {
|
||||
};
|
||||
}
|
||||
|
||||
/** Put saved zoom back onto the author's structure. A panel id nobody saved keeps whatever `source` says. */
|
||||
function applyZooms(node: LayoutNode, zooms: Zooms): LayoutNode {
|
||||
if (!isGroup(node)) {
|
||||
const saved = zooms[node.id];
|
||||
if (saved === undefined || saved === node.zoom) return node;
|
||||
return { ...node, zoom: saved };
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
children: node.children.map((child) => ({ ...child, node: applyZooms(child.node, zooms) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The author's structure wearing the user's view. The only way either half of this hook builds a tree. */
|
||||
const graft = (source: LayoutNode, stored: LayoutNode): LayoutNode =>
|
||||
applyZooms(applySizes(source, collectSizes(stored)), collectZooms(stored));
|
||||
|
||||
/**
|
||||
* Same shape as `useDashboardState`, so a screen swaps one call and changes nothing else.
|
||||
*
|
||||
* `setValue` accepts a whole layout because that is what `WorkspaceView` reports — a resize hands back the
|
||||
* entire tree. Everything but its sizes is discarded here, which is precisely the point: the view is free
|
||||
* to report a normalised, repaired, or otherwise rewritten structure and none of it can be stored.
|
||||
* entire tree. Everything but the user's view is discarded here, which is precisely the point: the view is
|
||||
* free to report a normalised, repaired, or otherwise rewritten structure and none of it can be stored.
|
||||
*/
|
||||
export function useAuthoredLayout(key: string, source: LayoutNode) {
|
||||
const stored = useDashboardState<LayoutNode>(key, source);
|
||||
|
||||
const value = useMemo(() => applySizes(source, collectSizes(stored.value)), [source, stored.value]);
|
||||
const value = useMemo(() => graft(source, stored.value), [source, stored.value]);
|
||||
|
||||
const setValue = useCallback(
|
||||
(update: LayoutNode | ((prev: LayoutNode) => LayoutNode)) => {
|
||||
const next = typeof update === 'function' ? update(value) : update;
|
||||
const merged = applySizes(source, collectSizes(next));
|
||||
const merged = graft(source, next);
|
||||
|
||||
/*
|
||||
A write that changes nothing is dropped, and this is not an optimisation — it is what stops an
|
||||
@@ -109,6 +157,10 @@ export function useAuthoredLayout(key: string, source: LayoutNode) {
|
||||
panel keys, so every render of `/browser` produced a repair. Shipping the sizes-only hook turned a
|
||||
silent, harmless fallback into a hard crash on that screen.
|
||||
|
||||
It is also why a field this hook forgets to graft is INVISIBLE rather than merely unsaved: the
|
||||
change round-trips to the same tree, compares equal, and is dropped here. That is exactly how zoom
|
||||
was lost.
|
||||
|
||||
Comparing the serialised value is enough — these trees are small, this runs only on a real change,
|
||||
and it is the same check `setValue` would otherwise force React Query to make against a new object.
|
||||
*/
|
||||
@@ -123,4 +175,4 @@ export function useAuthoredLayout(key: string, source: LayoutNode) {
|
||||
}
|
||||
|
||||
/** The tree walking, exposed for its test. Not part of the hook's API — import the hook. */
|
||||
export const __testing = { collectSizes, applySizes };
|
||||
export const __testing = { collectSizes, applySizes, collectZooms, applyZooms, graft };
|
||||
|
||||
@@ -204,6 +204,51 @@
|
||||
background-attachment: fixed;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -149,7 +149,20 @@ export function useThemeKeys(options: ThemeKeysOptions = {}) {
|
||||
would be indefensible.
|
||||
*/
|
||||
const target = event.target as HTMLElement | null;
|
||||
const typing = Boolean(target && (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName)));
|
||||
/*
|
||||
A CODE EDITOR is typing too, and it does not look like it.
|
||||
|
||||
Monaco's modern input surface is neither a textarea nor contenteditable — it is a plain div the
|
||||
EditContext API writes into — so a tagName test misses it entirely and `ctrl+shift+←/→` cycled
|
||||
the theme while somebody was selecting words in a file. `.monaco-editor` is the class its root
|
||||
always carries; `closest` because the event lands on whatever is inside it.
|
||||
*/
|
||||
const typing = Boolean(
|
||||
target &&
|
||||
(target.isContentEditable ||
|
||||
/^(input|textarea|select)$/i.test(target.tagName) ||
|
||||
target.closest?.('.monaco-editor, [data-typing]')),
|
||||
);
|
||||
|
||||
/*
|
||||
Star or unstar the CURRENT theme, from anywhere, list open or not.
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
id: backburner/m3e-canvas-as-a-plugin
|
||||
title: "M3E Canvas as a plugin — sketch a UI, hand the prompt to the agent"
|
||||
theme: backburner
|
||||
status: open
|
||||
created: 2026-09-04
|
||||
date_source: stated
|
||||
order: 4
|
||||
---
|
||||
|
||||
**M3E Canvas as a plugin.** [github.com/lnkiai/m3e-canvas](https://github.com/lnkiai/m3e-canvas) — a
|
||||
browser design tool for Material 3 Expressive screens that exports the design as a natural-language
|
||||
prompt for an AI coding tool. MIT.
|
||||
|
||||
The upstream question is now **answered** — see below; vendoring is the path. The rest is still open.
|
||||
This card holds the measurements so the decision is made from numbers rather than from a README. Everything below was read from the code at **`cfde698`, 2026-09-04**.
|
||||
|
||||
## Why it is interesting
|
||||
|
||||
Its output is *a prompt you paste into Claude*. Officer is the thing that removes the paste — the export
|
||||
button could open a chat session with the prompt already in it, pointed at a project directory on this
|
||||
machine. **That last step is the only part Officer uniquely adds, and it is the whole reason to bother.**
|
||||
The canvas already works standalone today.
|
||||
|
||||
## What it actually is
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| size | 65 files, **14,822 lines** of TS/TSX |
|
||||
| deps | `next` 16.3.4, `react` 19.2.8, `motion` ^13.1.1, `html-to-image` ^1.11.13 |
|
||||
| licence | MIT |
|
||||
| storage | `localStorage` only, no backend |
|
||||
|
||||
**Next.js is not actually there.** The entire codebase imports it **once**, and it is a type-only import:
|
||||
`import type { Metadata, Viewport } from "next"` in `app/layout.tsx`. Eighteen files carry `'use client'`.
|
||||
So it is a client-side React SPA that happens to be scaffolded in Next — there is nothing to strip, and
|
||||
porting means giving it a different mount point.
|
||||
|
||||
That is the finding that changes the estimate. An earlier read of the README concluded "weeks of work,
|
||||
own a fork forever", and that was wrong.
|
||||
|
||||
## The fork line falls on a file boundary
|
||||
|
||||
`app/page.tsx` imports about **twenty modules, every one a named export** through the `@/` alias, and
|
||||
reaches into no internals. `lib/tokens.ts` is the shared spine with 16 importers. `app/` holds exactly
|
||||
one file that matters.
|
||||
|
||||
| | lines | |
|
||||
| --- | --- | --- |
|
||||
| `app/page.tsx` | 3,433 | the whole editor: 15 components, 103 hook calls. **Yours.** |
|
||||
| `lib/ai.ts` | ~200 | **Delete it** — stores provider API keys in `localStorage`. Officer has a secret store and the agent. |
|
||||
| `lib/tokens.ts` | 1,614 | Material 3 tokens. Data. Vendor. |
|
||||
| `components/M3Node.tsx` | 1,233 | the component renderer. Vendor. |
|
||||
| `components/Inspector.tsx` | 1,171 | Vendor. |
|
||||
| `lib/prompt.ts` | 1,151 | the export engine. Vendor. |
|
||||
| `components/Preview.tsx` | 899 | Vendor. |
|
||||
| `components/ui.tsx` | 882 | Vendor. |
|
||||
| rest | ~4,200 | `Mobile`, `i18n`, `tidy`, `Toolbar`, `color`, `shapes`, `theme`, `project`. Vendor. |
|
||||
|
||||
**~3,600 lines owned, ~11,200 vendored** — and the split means something. What is vendored is *"what a
|
||||
Material 3 component looks like and how to describe it in words"*, where upstream's value accumulates and
|
||||
we have no wish to innovate. What is owned is *"where does this live in the workspace, whose is it, and
|
||||
what happens on export"*, where upstream can never help because it does not know Officer exists.
|
||||
|
||||
**So deep integration and tracking upstream are not in tension.** The integration work lives entirely in
|
||||
the one file that was always going to be rewritten.
|
||||
|
||||
## The export point is clean
|
||||
|
||||
```ts
|
||||
buildPrompt(doc: Doc, widths: Record<string, number>, onlyFrameId?: string, lang: Lang): string
|
||||
effectivePrompt(doc, widths, lang): string
|
||||
```
|
||||
|
||||
Two pure functions in `lib/prompt.ts`. `Doc` in, string out, no side effects. No `postMessage` bridge and
|
||||
no clipboard interception needed — **call the function.** Today it ends in
|
||||
`navigator.clipboard.writeText` in three places; that is what gets replaced.
|
||||
|
||||
`Doc` is plain serialisable JSON (`lib/project.ts` already downloads and reloads it), so persisting to a
|
||||
plugin table instead of `localStorage` is nearly free. Current keys: `DOC_KEY` and `UI_KEY` in
|
||||
`page.tsx`, `m3e:ai`, and `m3e:sec:<id>`.
|
||||
|
||||
## The upstream problem, measured
|
||||
|
||||
This is the part that makes it a backburner card rather than a build.
|
||||
|
||||
- **37 commits, 3 days old** — first 2026-09-02, and **22 of them landed on 2026-09-04**, the day this
|
||||
was read.
|
||||
- **Two human authors** (`lnkiai` 25, `CQMHV` 10) plus dependabot.
|
||||
|
||||
It is under heavy active development *right now*. The export surface will churn, and no amount of
|
||||
discipline on our side changes that. "Vendor it and track upstream" assumes an upstream that has settled,
|
||||
and this one has not started.
|
||||
|
||||
### If it is vendored anyway
|
||||
|
||||
`web/vendor/m3e/`, committed with upstream URL, commit SHA and date in a README beside it. **The
|
||||
precedent is already in the tree** — `plugins/jellyfin/web/vendor/` and `public/vendor/pako/`.
|
||||
|
||||
The piece that makes it survivable is **an adapter**: Officer's panels import from one `adapter.ts` that
|
||||
re-exports from `vendor/`, never from `vendor/` directly. An upstream rename then costs one file instead
|
||||
of twenty. Updating is: re-copy, read the diff, fix the seam.
|
||||
|
||||
## Asked upstream, 2026-09-04. Declined — vendoring is the path
|
||||
|
||||
[Issue #55](https://github.com/lnkiai/m3e-canvas/issues/55) asked for the seam;
|
||||
[PR #56](https://github.com/lnkiai/m3e-canvas/pull/56) implemented the smaller half of it. Both closed
|
||||
unmerged on 2026-09-05. The reply is worth reading in full, but the two reasons were:
|
||||
|
||||
1. **The PR did not actually unblock embedding.** It wired one of the three clipboard writes —
|
||||
`app/page.tsx` — and left `components/Inspector.tsx:443` and `components/PromptPanel.tsx:35`
|
||||
writing directly. So it "adds a public surface I'd have to keep stable" without delivering the
|
||||
thing it was for. **That was our error, not a judgement call**: the issue text named all three
|
||||
sites and the PR wired one.
|
||||
2. **Embedding is not on their roadmap, and it is a single-user need so far.** Fair on both counts.
|
||||
|
||||
**What they said they would want** if that changes, quoted because it is the specification for any
|
||||
future attempt:
|
||||
|
||||
> If I decide to support embedding I'd want to do it in one piece: the `Canvas` extraction plus the
|
||||
> adapter wired into every export path. Your design for the adapter is sound and I'd start from it
|
||||
> then.
|
||||
|
||||
So the adapter design survives — it is validated by the person who would have to maintain it — and the
|
||||
work it needs is known: **the file move plus all three export paths**, not one of them.
|
||||
|
||||
The branch is on the fork at [andrepadez/m3e-canvas `embeddable-canvas`](https://github.com/andrepadez/m3e-canvas/tree/embeddable-canvas),
|
||||
two commits, typecheck and build green. Worth keeping: if this is vendored, that is the diff to apply
|
||||
on top, extended to the other two call sites.
|
||||
|
||||
### What this settles
|
||||
|
||||
**Vendor, do not depend.** The upstream door is open in principle and shut in practice, so the fork is
|
||||
the only route to the integration — which is what the split below was already designed for. Nothing
|
||||
about the file-boundary analysis changes; only the hope of upstreaming the shell does.
|
||||
|
||||
## If it is picked up anyway
|
||||
|
||||
**Open an issue asking upstream to make the seam explicit.** If `page.tsx` grew an entry point taking the
|
||||
persistence layer and the export sink — `<Canvas storage={…} onExport={…} />` — we would **depend rather
|
||||
than fork**, and the 3,600 owned lines shrink to a few hundred.
|
||||
|
||||
**Answered: no, for now.** Recorded above. Asking cost an afternoon and bought a specification for
|
||||
what upstream would accept, plus confirmation that the adapter shape is right — which is a good return
|
||||
on a declined PR.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Is this a design tool that lives in Officer, or the front end of "sketch it and the agent builds
|
||||
it"?** If the second, `page.tsx` is where we deliberately diverge, and adopting the fork is the right
|
||||
frame from day one rather than something discovered in six months.
|
||||
2. **Whose designs are they?** `Doc` is per-user data. Multi-user makes that a permissions question, and
|
||||
the plugin table answers it in a way `localStorage` cannot.
|
||||
3. **Does the phone editor survive the port?** `components/Mobile.tsx` (534 lines) is a separate
|
||||
phone-optimised editor. Officer's mobile story is a native app, so this may be redundant or may be the
|
||||
best part.
|
||||
4. **An iframe was considered and rejected.** It buys a day and costs the function call, the theming, the
|
||||
persistence, and forces back the clipboard round-trip the whole idea exists to remove. Recorded so it
|
||||
is not re-proposed as the cheap option.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
theme: integrations
|
||||
heading: 'Integrations — external tools worth wrapping as a plugin'
|
||||
kind: theme
|
||||
---
|
||||
|
||||
Third-party self-hosted tools evaluated for a plugin, with what was actually checked (license,
|
||||
activity, self-hosting shape, credential cost) rather than a name someone liked.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
id: integrations/openseo-a-plugin-candidate
|
||||
title: 'OpenSEO — a strong plugin candidate, checked rather than assumed'
|
||||
theme: integrations
|
||||
status: open
|
||||
created: 2026-08-27
|
||||
date_source: stated
|
||||
order: 1
|
||||
---
|
||||
|
||||
**OpenSEO (`every-app/open-seo`) — worth building a plugin around.** Asked to be evaluated 2026-08-27;
|
||||
checked against gitea rather than taken on the README's word.
|
||||
|
||||
**What it is:** a self-hosted, open-source alternative to Semrush/Ahrefs — keyword research, rank
|
||||
tracking, competitor insights, backlinks, site audits, AI visibility. MIT licensed, TypeScript,
|
||||
**14.4k stars, 1.7k forks, pushed 3 days ago** (verified via `gh repo view`, not the README's own
|
||||
framing) — a real, active project, not an abandoned demo.
|
||||
|
||||
**Why it fits this platform specifically, more than most tools would:** it ships **its own MCP server
|
||||
and Agent Skills**, built explicitly to be driven by "Claude Code, OpenClaw, or Hermes." That is
|
||||
Officer's whole plugin thesis already built by someone else — most candidates need Officer to invent
|
||||
the agent-facing surface; this one already has one.
|
||||
|
||||
**The self-hosting shape is the easy case, verified from `compose.yaml` and the docs, not assumed:**
|
||||
|
||||
- **One container, no separate database.** Cloudflare D1 (SQLite) by default — Postgres is opt-in only
|
||||
for installs that outgrow it (`docs/LOCAL_POSTGRES.md`). Same shape as Jellyfin, Gitea, Transmission:
|
||||
a single sidecar, nothing extra to provision.
|
||||
- **One required credential**: a DataForSEO API key, paid pay-as-you-go DIRECTLY to DataForSEO — not a
|
||||
subscription to OpenSEO itself unless using their hosted version. Two more are optional
|
||||
(OpenRouter for its own AI features, Google OAuth for Search Console). Real cost, worth saying
|
||||
plainly: this is free software over a metered API, not a free service.
|
||||
- **Self-hosting runs `AUTH_MODE=local_noauth` by design** — their own docs say explicitly _"only
|
||||
expose it behind your own auth-protected reverse proxy, tunnel, or private network."_ That sentence
|
||||
describes exactly what an Officer plugin already is: mounted behind Officer's own permission gate,
|
||||
never exposed on its own. This is the ordinary case for a plugin here, not a special risk to design
|
||||
around.
|
||||
|
||||
**What a plugin would actually need to add, and it may be less than usual:** because OpenSEO already
|
||||
runs its own MCP server, the plugin's job is closer to credential plumbing and a UI surface
|
||||
(`osDependencies`-style container/sidecar, the DataForSEO key in the per-user/server credential store,
|
||||
a panel embedding or proxying its web UI) than inventing new MCP tools from scratch — most of the
|
||||
agent-facing work is already done upstream.
|
||||
|
||||
Open, for whoever picks this up: whether Officer's agents call OpenSEO's own MCP endpoint directly
|
||||
(simplest, if reachable over whatever network the plugin runs on) or Officer wraps it as
|
||||
Officer-native MCP tools for consistency with how other plugins expose theirs; and where the container
|
||||
runs given `todo/infra/opm-officer-s-own-process-manager` is heading toward the Docker network
|
||||
specifically so container-backed dependencies like this one need no published host port.
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
id: known-bugs/officer-blocks-its-event-loop-parsing-transcripts
|
||||
title: 'Officer blocks its own event loop for ~0.9s re-parsing a live transcript, and everything it serves stalls with it'
|
||||
theme: known-bugs
|
||||
status: open
|
||||
created: 2026-08-29
|
||||
date_source: stated
|
||||
order: 7
|
||||
---
|
||||
|
||||
**Found 2026-08-29 while chasing "Minesweeper is laggy today".** It is not a Minesweeper bug. The game's
|
||||
display is relayed through officer, and officer periodically stops answering anything for up to a second.
|
||||
|
||||
**The measurements, in order.** Press-to-paint at x11vnc's own socket: **median 29ms, max 35ms**, stable
|
||||
even with a 1.5s idle gap before each click. Through the sidecar's own `/ws` relay: **median 29ms, max
|
||||
43ms** — so the sidecar adds nothing. In the browser, where it is actually felt: **35ms to 200ms+, varying
|
||||
per click**. All of the variance is introduced between the sidecar and the browser, and the only thing
|
||||
there is officer.
|
||||
|
||||
Officer answering a trivial unauthenticated 401 on `/api/health`, 30 samples: **median 1ms, p90 2ms, max
|
||||
490ms.** The loop is fine until it is blocked outright.
|
||||
|
||||
**What blocks it.** `summarizeTranscript` (`api/chat/claude-sessions.ts`) full-reads and `JSON.parse`s a
|
||||
whole transcript to build a session-list row. Timed against the largest on this machine — 196 MB, 70,137
|
||||
entries: `readFileSync` **323ms**, split + parse every line **541ms**, **864ms blocking, per call**.
|
||||
|
||||
It is cached, and the cache cannot help. The key is mtime, and the comment above it says why that was
|
||||
believed to be exact: _"Claude only ever appends"_. But a **live** session appends every few seconds, so
|
||||
its mtime moves, the entry misses, and the whole 196 MB is re-read and re-parsed — for a row in a list.
|
||||
The busier the agent, the more often. `468 MB across 174 transcripts` here, and the biggest one belongs to
|
||||
a commander that has been running all day.
|
||||
|
||||
**Why it reads as a Minesweeper regression.** Nothing in the plugin changed (last commit two days ago), and
|
||||
the same cost was invisible when that transcript was 10 MB and this took ~45ms. It scales with transcript
|
||||
size, so it arrives gradually and then all at once.
|
||||
|
||||
**Scope is much wider than the game.** Every HTTP request, every chat socket frame and every relayed plugin
|
||||
websocket shares that loop. The game is just the one surface where a 500ms stall is impossible to miss.
|
||||
|
||||
**The fix the file argues for itself.** _"Claude only ever appends"_ is exactly what makes incremental
|
||||
summarisation correct: keep the running summary keyed on `(path, size)` and consume only the bytes appended
|
||||
since last time, instead of re-reading from zero. Turns ~864ms into the cost of the new tail. `listClaudePwds`
|
||||
already does a bounded head read for `cwd` (`claude-sessions.ts`, "a bounded head read"), so the precedent
|
||||
for not reading the whole file is in the same module.
|
||||
|
||||
Not attempted here: it is a hot path shared by every surface, and the right shape — incremental parse versus
|
||||
moving summaries out of the request path entirely — is an architecture call rather than a patch.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
id: known-bugs/radicale-cannot-be-installed-on-macos
|
||||
title: 'caldav retries `brew install radicale` on every boot, and there is no such formula'
|
||||
theme: known-bugs
|
||||
status: open
|
||||
created: 2026-09-22
|
||||
date_source: stated
|
||||
order: 8
|
||||
---
|
||||
|
||||
**On macOS, caldav fails its own dependency install on every single boot**, logging a line that reads like
|
||||
a transient error and is permanent:
|
||||
|
||||
```
|
||||
[caldav] radicale install failed (exit 1): … Warning: No available formula with the name "radicale".
|
||||
```
|
||||
|
||||
Confirmed on `macbookpro` (macOS 26.6.2) 2026-09-22 — four consecutive boots, four identical failures.
|
||||
|
||||
**Two things are wrong and only one is caldav's.** `plugins/caldav/manifest.ts` declares
|
||||
`packages: { apt, pacman, dnf }` with **no `brew` entry**, so the installer falls back to the binary name
|
||||
and runs `brew install radicale`. And the fallback cannot succeed anyway: **radicale is not a Homebrew
|
||||
formula.** It is a Python application, installed with `pipx install radicale` or `uv tool install radicale`.
|
||||
Homebrew's suggestion in the log — "Did you mean radicle or cadical?" — is two unrelated projects.
|
||||
|
||||
So the manifest has nothing correct to put in a `brew` field, which is the actual gap:
|
||||
`PackageManager` is `'apt' | 'pacman' | 'dnf' | 'brew'` (`plugins/manifest.ts`). There is no way to express
|
||||
"this comes from pipx", and no way to say "not available on this platform" — so the only outcomes are a
|
||||
wrong guess retried forever, or silence.
|
||||
|
||||
Consequences, in order of how much they matter: caldav does not work on that machine at all (no calendars,
|
||||
no contacts — the manifest's own `reason` says so); the failure repeats every boot with no backoff and no
|
||||
memory; and the log line is indistinguishable from something worth chasing, which is how it cost a
|
||||
debugging session that had been opened about something else entirely.
|
||||
|
||||
Worth deciding rather than patching: either `PackageManager` grows a `pipx`/`uv` member, or
|
||||
`osDependencies` grows a way to declare a platform where a binary is genuinely unavailable, so the
|
||||
installer can say it once and stop.
|
||||
|
||||
---
|
||||
|
||||
**Found alongside, and NOT a bug — recorded so the next person does not chase it.** The same log carried
|
||||
`[plugins] frontend rebuild failed: ResolveMessage: Could not resolve: "motion/react"`, which looked like
|
||||
the real failure and was already over. It appeared on exactly one boot: the m3e-canvas plugin had landed on
|
||||
disk before `plugins/node_modules/motion` existed. Verified on the machine afterwards — `motion` is listed
|
||||
in the generated `plugins/package.json`, installed, and `import('motion/react')` resolves with 398 exports;
|
||||
the current boot rebuilds cleanly.
|
||||
|
||||
**The gap it exposes is real even though it healed.** `installNpmDependencies` is called from exactly one
|
||||
place, `plugins/install.ts`, the install flow. Boot (`plugins/mount.ts`) rebuilds the plugin frontend and
|
||||
never installs anything. So a plugin that reaches `plugins/` without that flow completing — cloned by hand,
|
||||
or an install interrupted — leaves every subsequent boot failing the rebuild with a message that says
|
||||
_"Maybe you need to bun install"_ while nothing ever will. Here the install finished and the next boot was
|
||||
fine; on a machine where it does not, the platform cannot recover on its own.
|
||||
Reference in New Issue
Block a user