Compare commits
99
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
03fbb1189f | ||
|
|
21665ed024 | ||
|
|
111618bd7f | ||
|
|
9cb875ca79 | ||
|
|
c5b95becd2 | ||
|
|
f4b9596a88 | ||
|
|
09518e1e1f | ||
|
|
500bb925eb | ||
|
|
a970cb7c5f | ||
|
|
fb1cf0f6f1 | ||
|
|
a947df8d9d | ||
|
|
78bf63c209 | ||
|
|
347acf0171 | ||
|
|
766b18b7f9 | ||
|
|
e4f5e03105 | ||
|
|
3b0083aec2 | ||
|
|
58f50a5240 | ||
|
|
43a4463978 | ||
|
|
de425d9d68 | ||
|
|
22a4c65776 | ||
|
|
0d310ab7b0 | ||
|
|
16fa869260 | ||
|
|
3d936309a5 | ||
|
|
9a70089d6b | ||
|
|
e2d03394e1 | ||
|
|
cac58ea044 | ||
|
|
5c6c5b31f8 | ||
|
|
d273dc6a14 | ||
|
|
4e0a065f0e | ||
|
|
f4685eeafe | ||
|
|
87d1740caf | ||
|
|
9c8af9d424 | ||
|
|
fb0cc880ac | ||
|
|
02ad4d3bdf | ||
|
|
91147b403b |
@@ -243,7 +243,7 @@ ahead of everything, and it re-verifies the token itself so it covers routes tha
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). **21 entries as of 2026-08-27** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 6 admin (`todos`, the todo board, is the sixth). It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
never grantable at any level). **20 entries as of 2026-08-29** — verified by `bun run docs:check`, which fails if this number drifts again: 6 core, 2 app, 4 confined, 3 execution, 5 admin. It was 21 until the todo board became the `kanban` plugin and took `todos` with it — the same shape as caldav below, and for the same reason: a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. It was 22 until caldav became a plugin and took `calendar` with it — a plugin's permission is registered at runtime from its manifest, so it is real but not in this file's count. `notify` moved from `app` to `core` on 2026-08-24: it was grantable, nothing granted it, and every Member's app was silently 403'd registering its own push token. It said 27 for nine days. The execution count dropped by one when Minesweeper became a plugin — see `plugins/minesweeper/manifest.ts` for why that was a fix rather than a downgrade.
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
|
||||
@@ -22,14 +22,14 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**107 open, 32 closed**, across 34 themes.
|
||||
**109 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,12 +40,13 @@ 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/) | 7 | 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 |
|
||||
| [Notifications](todo/notifications/) | 1 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 5 | 0 |
|
||||
| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 1 |
|
||||
| [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 |
|
||||
| [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 |
|
||||
| [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 |
|
||||
@@ -70,13 +71,13 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Newest ten
|
||||
|
||||
- `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",
|
||||
@@ -122,6 +123,7 @@
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"shiki": "^3.22.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sounds": "workspace:*",
|
||||
@@ -323,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=="],
|
||||
@@ -365,7 +369,7 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="],
|
||||
|
||||
@@ -443,31 +447,43 @@
|
||||
|
||||
"@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "os": "freebsd" }, "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.3", "", { "os": "linux", "cpu": "none" }, "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.3" }, "os": "linux", "cpu": "none" }, "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.3" }, "os": "linux", "cpu": "s390x" }, "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
@@ -475,11 +491,13 @@
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.4", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA=="],
|
||||
|
||||
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.4", "", { "dependencies": { "@img/sharp-wasm32": "0.35.4" }, "cpu": "none" }, "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
@@ -1601,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=="],
|
||||
@@ -2135,7 +2155,7 @@
|
||||
|
||||
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
@@ -2147,7 +2167,7 @@
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
"sharp": ["sharp@0.35.4", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.4", "@img/sharp-darwin-x64": "0.35.4", "@img/sharp-freebsd-wasm32": "0.35.4", "@img/sharp-libvips-darwin-arm64": "1.3.3", "@img/sharp-libvips-darwin-x64": "1.3.3", "@img/sharp-libvips-linux-arm": "1.3.3", "@img/sharp-libvips-linux-arm64": "1.3.3", "@img/sharp-libvips-linux-ppc64": "1.3.3", "@img/sharp-libvips-linux-riscv64": "1.3.3", "@img/sharp-libvips-linux-s390x": "1.3.3", "@img/sharp-libvips-linux-x64": "1.3.3", "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", "@img/sharp-libvips-linuxmusl-x64": "1.3.3", "@img/sharp-linux-arm": "0.35.4", "@img/sharp-linux-arm64": "0.35.4", "@img/sharp-linux-ppc64": "0.35.4", "@img/sharp-linux-riscv64": "0.35.4", "@img/sharp-linux-s390x": "0.35.4", "@img/sharp-linux-x64": "0.35.4", "@img/sharp-linuxmusl-arm64": "0.35.4", "@img/sharp-linuxmusl-x64": "0.35.4", "@img/sharp-webcontainers-wasm32": "0.35.4", "@img/sharp-win32-arm64": "0.35.4", "@img/sharp-win32-ia32": "0.35.4", "@img/sharp-win32-x64": "0.35.4" }, "peerDependencies": { "@types/node": "*" }, "optionalPeers": ["@types/node"] }, "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
@@ -2275,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=="],
|
||||
@@ -2419,6 +2441,20 @@
|
||||
|
||||
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
|
||||
|
||||
"@img/sharp-darwin-arm64/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-darwin-x64/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-linux-arm/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-linux-arm64/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-linux-x64/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
||||
@@ -2519,6 +2555,8 @@
|
||||
|
||||
"editorconfig/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
|
||||
"editorconfig/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"engine.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"engine.io/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
@@ -2557,6 +2595,8 @@
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"next/sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||
|
||||
"node-pty/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||
|
||||
"nostr-tools/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
|
||||
@@ -2597,35 +2637,23 @@
|
||||
|
||||
"send/mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
"sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA=="],
|
||||
|
||||
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
"sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.3" }, "os": "darwin", "cpu": "x64" }, "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.3" }, "os": "linux", "cpu": "arm" }, "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" }, "os": "linux", "cpu": "arm64" }, "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.3" }, "os": "linux", "cpu": "x64" }, "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||
"sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw=="],
|
||||
|
||||
"sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||
|
||||
"sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||
|
||||
"sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||
|
||||
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
"sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.4", "", { "os": "win32", "cpu": "x64" }, "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg=="],
|
||||
|
||||
"socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
@@ -2727,6 +2755,46 @@
|
||||
|
||||
"hastscript/@types/hast/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||
|
||||
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||
|
||||
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||
|
||||
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||
|
||||
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||
|
||||
"next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||
|
||||
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||
|
||||
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||
|
||||
"next/sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"react-email/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"react-email/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.23.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ=="],
|
||||
@@ -2899,6 +2967,8 @@
|
||||
|
||||
"glob/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||
|
||||
"next/sharp/@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"react-email/glob/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="],
|
||||
|
||||
"wif/bs58check/bs58/base-x": ["base-x@5.0.1", "", {}, "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="],
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# 2026.08.27.02
|
||||
|
||||
Everything in `2026.08.27.01`, which was **refused before any host was contacted** — its plan named no
|
||||
commands and claimed "no schema change", which was false. This plan replaces it. `2026.08.27.01` stays
|
||||
claimed and undeployed; do not try to revive it.
|
||||
|
||||
Plus one fix made because of that refusal: a single 401 no longer signs the owner out of every tab.
|
||||
|
||||
## The schema question, answered per host
|
||||
|
||||
`officer_db/src/dav/schema.ts` was **deleted** — `dav_app_passwords` moved into `plugins/caldav`.
|
||||
`gen-plugin-schemas` follows plugin DIRECTORIES, so the table is only declared on a host that has
|
||||
`plugins/caldav/`. Everywhere else `bun db:push` will propose **dropping** it.
|
||||
|
||||
Surveyed 2026-08-28, every host:
|
||||
|
||||
| Host | `plugins/caldav/` | `dav_app_passwords` |
|
||||
| --- | --- | --- |
|
||||
| `alpha` | present | **8 rows** |
|
||||
| `fofos` | absent | present, 0 rows |
|
||||
| `macbookpro` | absent | present, not read (bun at `/opt/homebrew/bin`) |
|
||||
| `macbruno` | absent | present, 0 rows |
|
||||
| `production-pertento-bridge` | absent | present, 0 rows |
|
||||
| `edge-pertento-bridge` | absent | present, 0 rows |
|
||||
| `deadpool-rezio-bridge` | absent | not read (ssh refused at survey time) |
|
||||
|
||||
So: **alpha pushes normally.** Everywhere else the table is a leftover of a feature that has left core,
|
||||
and it is empty — but it is dropped **explicitly, with a guard**, not as a `db:push` side effect.
|
||||
|
||||
`officer_marketplace` and its new `tags` column: **no host runs `officer-marketplace`** — checked, zero
|
||||
of seven. Nothing to apply. If that changes, the column has no push script and must be added by hand.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.27.02`
|
||||
2. **Only if `plugins/caldav/` is ABSENT on this host** — retire the orphan table, refusing to if it
|
||||
holds anything:
|
||||
```
|
||||
psql "$POSTGRES_URL" -Atc "select count(*) from dav_app_passwords"
|
||||
# 0 → drop it: psql "$POSTGRES_URL" -c "drop table dav_app_passwords"
|
||||
# anything else → STOP. Report the count and do not continue on this host.
|
||||
```
|
||||
Read the variable name from `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`.
|
||||
3. `bun db:push < /dev/null` — **stdin closed on purpose**, so a push needing an answer fails instead of
|
||||
hanging. Expect **no changes** after step 2. Anything destructive: stop and report.
|
||||
4. `bun run plugins:update`
|
||||
5. `pm2 restart officer`
|
||||
6. `pm2 restart officer-opencode` — this release rewires how OpenCode gets its skills, its agent identity
|
||||
and its credential, and all of that is built when the sidecar boots. Without this the code ships and
|
||||
nothing about OpenCode changes.
|
||||
|
||||
**`alpha`:** skip step 2 entirely — it has the plugin and 8 real credentials in that table. Step 3 runs
|
||||
normally and must report no changes.
|
||||
|
||||
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
|
||||
with a note.
|
||||
|
||||
**`deadpool-rezio-bridge`:** login is `br`, checkout `/home/br/officerdev/platform`. SSH refused once
|
||||
during the survey — if it refuses again, note it and move on rather than retrying in a loop.
|
||||
|
||||
**Do not restart `officer-claude-code`.** It ends every live agent session on the host, nothing here needs
|
||||
it, and it is the owner's call.
|
||||
|
||||
## Risk
|
||||
|
||||
Moderate, and concentrated in step 2 — the only destructive command in this release. It is guarded by a
|
||||
count and it is skipped on the one host where the table has contents.
|
||||
|
||||
After the rollout, two things are worth a look: `useThemeAsset` now reads in `requestAnimationFrame`, so
|
||||
a themed logo or wallpaper failing to appear points here; and the notify router's mount order changed, so
|
||||
device registration is worth one check (it forwards correctly on `officer-dev`).
|
||||
|
||||
## Hosts
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
|
||||
`officer-dev` is skipped: it is the host running the rollout.
|
||||
@@ -0,0 +1,96 @@
|
||||
# 2026.08.27.02 — report
|
||||
|
||||
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout.
|
||||
|
||||
Every host moved to `91147b40` (`deploy/2026.08.27.02`) by `git merge --ff-only` onto the TAG. No host
|
||||
was dirty in tracked files, none diverged, none needed a skip.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | Step 2 (drop) | `dav_app_passwords` after | Restarted |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `e867fe72` | `91147b40` | skipped by plan | **present, 8 rows** | officer 0→1, opencode 0→1 |
|
||||
| `fofos` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 0→1 |
|
||||
| `macbookpro` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 0→1, opencode 0→1 |
|
||||
| `macbruno` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 1→2 |
|
||||
| `production-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 1→2, opencode 0→1 |
|
||||
| `edge-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 2→3, opencode 0→1 |
|
||||
| `deadpool-rezio-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 19→20, opencode 0→1 |
|
||||
|
||||
Every process the plan named came back `online` with `restart_time` moved, on every host. No officer
|
||||
process was left `errored` or `stopped` anywhere.
|
||||
|
||||
## The schema, verified by query per host
|
||||
|
||||
The plan's claim was checked on each host, against that host's own database, **after** the rollout —
|
||||
not inferred from an exit code. Final sweep:
|
||||
|
||||
```
|
||||
alpha caldav=yes dav_app_passwords=present rows=8
|
||||
fofos caldav=no dav_app_passwords=GONE
|
||||
macbookpro caldav=no dav_app_passwords=GONE
|
||||
macbruno caldav=no dav_app_passwords=GONE
|
||||
production-pertento-bridge caldav=no dav_app_passwords=GONE
|
||||
edge-pertento-bridge caldav=no dav_app_passwords=GONE
|
||||
deadpool-rezio-bridge caldav=no dav_app_passwords=GONE
|
||||
```
|
||||
|
||||
The count guard held on all six drop hosts: every one read exactly `0` before the `drop table`, and the
|
||||
drop was verified by re-querying `to_regclass` after. `alpha` was never touched by step 2, and its 8 rows
|
||||
are intact.
|
||||
|
||||
**Two counts the survey could not read are now read.** `macbookpro` (bun/psql behind Homebrew) and
|
||||
`deadpool-rezio-bridge` (SSH refused at survey time) both held **0 rows**, so both qualified for the drop
|
||||
under the plan's own guard rather than under an assumption.
|
||||
|
||||
`officer_marketplace` and its `tags` column: nothing applied, correctly — no host runs
|
||||
`officer-marketplace`, confirmed zero of seven.
|
||||
|
||||
## `bun db:push` prints `Changes applied` even when it changes nothing
|
||||
|
||||
Worth recording, because it makes the plan's "expect no changes" unverifiable from stdout. `db:push`
|
||||
printed `[✓] Changes applied` on **every** host, including on an immediate second run against an
|
||||
unchanged database. It is unconditional output, not evidence.
|
||||
|
||||
What settled it was `drizzle-kit push --verbose`, which prints the statements first. Rerun per host, the
|
||||
real content was:
|
||||
|
||||
- **every host** — `ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}'`
|
||||
- **`alpha` and `edge-pertento-bridge` also** — `music_now_playing` dropping and re-adding
|
||||
`pk_music_now_playing` as the same two-column key
|
||||
|
||||
Both re-appear on every push and neither is destructive. This is the drizzle-kit composite-key mis-diff
|
||||
already documented in `src/databases/CLAUDE.md`, and it is **pre-existing drift, unrelated to this
|
||||
release**. Nothing in it drops or recreates `dav_app_passwords` on any host — which is the positive
|
||||
result: plugin schema generation scoped the table to the one host with the plugin, exactly as the plan
|
||||
said it would.
|
||||
|
||||
Not fixed here. A rollout is the wrong place for a schema-code change.
|
||||
|
||||
## Plugins
|
||||
|
||||
`bun run plugins:update` exited 0 everywhere. It also started sidecars beyond the two the plan named, so
|
||||
"restarted" above understates what moved: `officer-gitea` was added on `fofos` and
|
||||
`production-pertento-bridge`, and `officer-offscale` on `edge-pertento-bridge` and
|
||||
`deadpool-rezio-bridge`. Counts: alpha 15/15, fofos 3/3, macbruno 1/1, production 1/1, edge 2/2,
|
||||
deadpool 2/2, macbookpro none installed.
|
||||
|
||||
## What was not done
|
||||
|
||||
- **`officer-dev` — skipped entirely**, as the plan requires. Not pulled, nothing restarted. It is the
|
||||
host this rollout ran on, and restarting `officer-claude-code` there would have ended the rollout. The
|
||||
owner restarts it deliberately.
|
||||
- **`officer-claude-code` was not restarted anywhere.** The plan forbids it.
|
||||
- **`deadpool-rezio-bridge` refused SSH on the first probe** (`Connection closed by 100.64.0.21 port 22`)
|
||||
and answered on a second attempt a few minutes later. One retry, not a loop. It then deployed normally,
|
||||
so it is a success rather than the skip the plan allowed for. Its SSH is intermittent and that is worth
|
||||
knowing.
|
||||
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could not
|
||||
touch it and it was left alone.
|
||||
- **`2026.08.27.01` remains claimed and undeployed**, which is correct: this release replaces it.
|
||||
|
||||
## Worth a look, per the plan's own risk note
|
||||
|
||||
Untested by this rollout, because they need a browser: `useThemeAsset` now reads in
|
||||
`requestAnimationFrame` (a themed logo or wallpaper failing to appear points there), and the notify
|
||||
router's mount order changed (device registration is worth one check).
|
||||
@@ -0,0 +1,71 @@
|
||||
# 2026.08.28.01
|
||||
|
||||
**One feature: sign in as a user, from user management.** Owner-only, one hour, on a token that carries
|
||||
`impersonatedBy` so an impersonated action is never mistaken for the person's own.
|
||||
|
||||
Two commits since `2026.08.27.02`, one of which is that release's own report.
|
||||
|
||||
## No schema change. No dependency change. No plugin change.
|
||||
|
||||
Checked, not asserted — `git diff --name-only deploy/2026.08.27.02..HEAD` matches no `schema.ts`, no
|
||||
`drizzle` config, no `bun.lock`, and nothing under `plugins/`. That was the claim the `2026.08.27.01`
|
||||
plan got wrong, so it is stated here as a check that was run rather than a belief.
|
||||
|
||||
## `bun db:push` is deliberately NOT a step
|
||||
|
||||
Every previous plan ran it "because it is cheap and proves the database agrees with the code". The
|
||||
`2026.08.27.02` report showed it proves nothing of the sort: it prints **"Changes applied" even when it
|
||||
applies nothing**, including on a second run against an unchanged database. And on this fleet it is not
|
||||
free — every host re-applies a default on `command_hq_sessions.reference_paths`, and `alpha` and
|
||||
`edge-pertento-bridge` drop and re-add `pk_music_now_playing`, which is the drizzle-kit composite-key
|
||||
mis-diff in `src/databases/CLAUDE.md`.
|
||||
|
||||
Nothing in this release touches a schema, so running it would be churn against a database for no reason,
|
||||
using a command whose output cannot be checked. **If you run it anyway, it is not evidence of anything.**
|
||||
|
||||
`bun run plugins:update` is skipped for the same reason: no plugin changed, and `2026.08.27.02` updated
|
||||
them across the fleet yesterday.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.28.01`
|
||||
2. `pm2 restart officer`
|
||||
|
||||
That is the whole rollout. The change is a route and a frontend bundle; nothing else runs it.
|
||||
|
||||
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
|
||||
with a note.
|
||||
|
||||
**`deadpool-rezio-bridge`:** login `br`, checkout `/home/br/officerdev/platform`. Its SSH is intermittent
|
||||
— it refused once during the `2026.08.27.02` survey and answered minutes later. One retry, not a loop.
|
||||
|
||||
**Do not restart `officer-claude-code`** — it ends every live agent session on the host and nothing here
|
||||
needs it. **`officer-opencode` does not need a restart either**: this release changes nothing it reads at
|
||||
boot.
|
||||
|
||||
## Verify, per host
|
||||
|
||||
The feature is owner-only, so an unauthenticated probe proving the route EXISTS is the honest check —
|
||||
401 or 403 means mounted, 404 means the bundle did not move:
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
|
||||
```
|
||||
|
||||
Expect **401**. A **404** means step 2 did not take effect. Ports are in `docs/servers.md` —
|
||||
`macbookpro` and `production-pertento-bridge` are on **9010**, everything else 9000.
|
||||
|
||||
## Risk
|
||||
|
||||
Low. One new route behind the existing owner gate, and one frontend bundle. The route refuses a
|
||||
self-impersonation, an unknown user, a non-Active account, and any caller whose own token is already
|
||||
impersonated — that last one is what stops it chaining into owner powers.
|
||||
|
||||
Nothing to roll back but the merge: no data is written and no schema is touched.
|
||||
|
||||
## Hosts
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
|
||||
`officer-dev` is skipped: it is the host running the rollout, and it is already at this code.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.28.01 — report
|
||||
|
||||
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout, and it
|
||||
was already at the tagged commit.
|
||||
|
||||
Every host moved from `91147b40` (`deploy/2026.08.27.02`) to `9c8af9d4` (`deploy/2026.08.28.01`) by
|
||||
`git merge --ff-only` onto the TAG. Every host started from the same commit, so the fleet was coherent
|
||||
before this release and is coherent after it. No host was dirty in tracked files, none diverged, none
|
||||
needed a skip.
|
||||
|
||||
This is the first release rolled out under the new watcher loop. The rollout procedure is the same one
|
||||
that ran the previous nine; what was unproven was the loop around it.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | Restarted | Route probe |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `alpha` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9000) |
|
||||
| `fofos` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
|
||||
| `macbookpro` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9010) |
|
||||
| `macbruno` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
|
||||
| `production-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 2→3, online | **401** (9010) |
|
||||
| `edge-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 3→4, online | **401** (9000) |
|
||||
| `deadpool-rezio-bridge` | `91147b40` | `9c8af9d4` | officer 20→21, online | **401** (9000) |
|
||||
|
||||
Every restarted process came back `online` with `restart_time` moved. Nothing was left `errored` or
|
||||
`stopped` on any host.
|
||||
|
||||
## The verification, per host
|
||||
|
||||
The plan's check is an unauthenticated probe of the new route: `401` proves it is mounted, `404` would
|
||||
mean the bundle did not move. Run on each host against that host's own port, taken from its `.env` and
|
||||
not from habit:
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
|
||||
```
|
||||
|
||||
All seven answered **401**. None answered 404, so the frontend bundle and the route moved everywhere.
|
||||
|
||||
Three hosts — `fofos`, `production-pertento-bridge`, `edge-pertento-bridge` — answered `000`
|
||||
(connection refused) on the first probe and `401` seconds later. That is `bun start` still coming up,
|
||||
not a fault: `pm2` returns as soon as it has respawned the process, before the server binds. A probe
|
||||
fired immediately after a restart is testing the wrong moment. The probes here retried until the port
|
||||
answered.
|
||||
|
||||
## No schema change — checked, not assumed
|
||||
|
||||
The plan claimed no schema change, no dependency change and no plugin change. That claim was
|
||||
**re-derived before the first host was touched**, not taken on trust:
|
||||
|
||||
```
|
||||
git diff --name-only deploy/2026.08.27.02..deploy/2026.08.28.01^{}
|
||||
```
|
||||
|
||||
Ten files: two `deploy/*.md`, `package.json`, and six under `src/` — a route, a router line, two
|
||||
frontend files, a workspace app and its index. No `schema.ts`, no drizzle config, no `bun.lock`,
|
||||
nothing under `plugins/`.
|
||||
|
||||
So there is no schema verification section in this report, because there was nothing to verify. That is
|
||||
the honest answer rather than an omission.
|
||||
|
||||
## Steps the plan deliberately did NOT include, and which were NOT run
|
||||
|
||||
- **`bun db:push` was not run on any host.** The plan forbids it and gives the reason: it prints
|
||||
`Changes applied` even when it applies nothing, so it is not evidence, and on this fleet it is not
|
||||
free — it re-applies a default on `command_hq_sessions.reference_paths` everywhere, and drops and
|
||||
re-adds `pk_music_now_playing` on `alpha` and `edge-pertento-bridge`. Nothing in this release touches
|
||||
a schema, so running it would have been churn.
|
||||
- **`bun run plugins:update` was not run on any host.** No plugin changed, and `2026.08.27.02` updated
|
||||
them across the fleet yesterday.
|
||||
- **`officer-claude-code` was not restarted anywhere.** It ends every live agent session on the host and
|
||||
nothing in this release needs it.
|
||||
- **`officer-opencode` was not restarted anywhere.** This release changes nothing it reads at boot.
|
||||
|
||||
The consequence worth stating: only `officer` moved on each host. Unlike `2026.08.27.02`, where the
|
||||
plugins step bounced every sidecar, nothing else on the fleet restarted during this rollout.
|
||||
|
||||
## Version agreement
|
||||
|
||||
The tag `deploy/2026.08.28.01` is annotated and dereferences to `9c8af9d4`. `package.json` at that
|
||||
commit reads `2026.08.28.01`. They agree, so there was no question of which tree to deploy.
|
||||
|
||||
## Say what was not done
|
||||
|
||||
- **`officer-dev` was skipped entirely** — not pulled, nothing restarted. It is the machine this
|
||||
rollout ran on, and restarting `officer-claude-code` there would have ended the session mid-rollout.
|
||||
It is already at `9c8af9d4`, so its code is current; what it has not had is a restart of `officer`.
|
||||
**That is the owner's to do, deliberately, as the last step.** Until then `officer-dev` is running the
|
||||
previous release's process against the new code on disk.
|
||||
- **`fofos` carries an untracked `scripts/chk.ts`.** Left alone. An untracked file is a note, not a
|
||||
reason to skip a host, and a fast-forward could never have touched it.
|
||||
- **The eight non-Officer processes on `production-pertento-bridge` were not touched.** `Dashboard`,
|
||||
`Tracking`, `Uptime` and five `Crons *` were all `online` with unchanged `restart_time` after the
|
||||
rollout — checked, not assumed. The restart named `officer` alone.
|
||||
- **No host needed a retry for SSH.** `deadpool-rezio-bridge`, which refused once during the
|
||||
`2026.08.27.02` survey, answered first time. `macbookpro` was awake.
|
||||
- **Nothing was rolled back and nothing needed repair.** No host failed, so no half-applied state
|
||||
exists anywhere.
|
||||
@@ -0,0 +1,122 @@
|
||||
# 2026.08.29.01 — sharp becomes infrastructure
|
||||
|
||||
**This release changes what a machine must have.** Every other deploy this month was code; this one adds
|
||||
a dependency with a native binary and a system package, so `bun install` is a required step rather than
|
||||
an optional one, and there are two things to verify per host that no previous plan asked for.
|
||||
|
||||
Read the whole file before starting a host. The per-host section at the bottom is not optional detail —
|
||||
one host needs a command none of the others do.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **File browser thumbnails.** Images and videos draw a picture in both grid and list. New endpoint
|
||||
`GET /api/file-browser/thumb`, cached under each user's `cache/thumbs/`.
|
||||
- **sharp is now a declared dependency** (`^0.35.3`, resolving 0.35.4). It does the images; ffmpeg keeps
|
||||
the video. `package.json` and `bun.lock` both changed.
|
||||
- **ffmpeg is now declared in machine-setup's package list.** It was always a hard dependency of the file
|
||||
browser — subtitles, audio extraction, audio tracks — and was never in the list. Existing hosts were
|
||||
not provisioned by the new script, so this release checks rather than assumes.
|
||||
- Marketplace: install a plugin from a git URL; Marketplace overview section; nav restyle.
|
||||
- Dock and Spotlight clip bare plugin artwork to the tile shape.
|
||||
- The platform-skill generator emits an identifier for `name` again, so `docs:check` is green.
|
||||
|
||||
## Schema: UNCHANGED. Do not run `db:push`.
|
||||
|
||||
Verified, not assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
|
||||
```
|
||||
|
||||
returns nothing. There is no table, no column and no constraint in this release. **No `bun db:push`
|
||||
step exists in this plan and none should be improvised.** If `bun run plugins:update` reports that a
|
||||
plugin moved, say so in the report — do not push a schema to cover it.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
Run these after the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin
|
||||
--tags`, `git merge --ff-only deploy/2026.08.29.01`, `bun run plugins:update`), in this order, in the
|
||||
checkout from PM2's `pm_cwd`.
|
||||
|
||||
### 1. Install — REQUIRED, and it is the point of this release
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
The lockfile is frozen, so this resolves from `bun.lock` and nothing else. It should report installing
|
||||
around 14 packages — sharp and its platform-specific libvips binaries.
|
||||
|
||||
**A failure here stops this host.** Do not continue to the restart, and do not reach for `--force` to get
|
||||
past it: if the lockfile and `package.json` disagree, that is the freeze doing its job and it wants a
|
||||
human.
|
||||
|
||||
### 2. Prove sharp actually loads — the check that matters
|
||||
|
||||
```bash
|
||||
bun -e "import('sharp').then(m => console.log('libvips', m.default.versions.vips)).catch(e => { console.error('SHARP FAILED:', e.message); process.exit(1) })"
|
||||
```
|
||||
|
||||
Expect `libvips 8.18.6`.
|
||||
|
||||
**This is a separate step from the install on purpose.** sharp does not compile — it downloads a prebuilt
|
||||
binary chosen by platform and architecture. So `bun install` can succeed and the module still be
|
||||
unusable, and the symptom is not an error anywhere: it is a file browser drawing generic icons over a
|
||||
folder of photographs, which nobody reads as "a native module is missing".
|
||||
|
||||
A host where this fails is a **failed host**. Report it, move on. `bun install --force` is the usual
|
||||
repair and it is a human's to run, not this rollout's.
|
||||
|
||||
### 3. ffmpeg must be present
|
||||
|
||||
```bash
|
||||
command -v ffmpeg && ffmpeg -version | head -1
|
||||
```
|
||||
|
||||
Video thumbnails need it, and so do three file-browser features that predate this release. Surveyed
|
||||
2026-08-29: present on every host **except `macbruno`** — see below.
|
||||
|
||||
Absent and not covered by a per-host command below → report the host as deployed-with-a-caveat, name
|
||||
ffmpeg, and do not install it yourself. Adding a system package to a machine is not something a rollout
|
||||
improvises.
|
||||
|
||||
### 4. Restart
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
**`officer` only.** Nothing else in this release touches a sidecar. Never
|
||||
`pm2 restart ecosystem.config.cjs`.
|
||||
|
||||
### 5. Verify it came back
|
||||
|
||||
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Then, for this release specifically:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://127.0.0.1:<PORT>/api/health
|
||||
```
|
||||
|
||||
`200` or `401` are both healthy — the port is per host, in `docs/servers.md` (9010 on `macbookpro` and
|
||||
`production-pertento-bridge`, 9000 elsewhere).
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Extra command | Note |
|
||||
| --- | --- | --- |
|
||||
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here. Do not pull, do not restart. Name it as skipped. |
|
||||
| `alpha` | — | x86_64, ffmpeg present. |
|
||||
| `fofos` | — | **Already at this code and verified** — it was the test host on 2026-08-29 before the tag. Bring it to the tag anyway so its HEAD matches the fleet; `bun install` will be a no-op. 1.9GB box: expect the install to be the slowest part. |
|
||||
| `macbookpro` | — | macOS/arm64, port **9010**. A laptop — unreachable is a SKIP with a note, not a failure. |
|
||||
| `macbruno` | `brew install ffmpeg` **before step 4** | macOS/arm64, login `brunoalcadapadezmonteirodaconceicao`. **The only host without ffmpeg.** Without it, image thumbnails still work — sharp does those — and video ones do not, along with subtitles and audio extraction. If brew is missing or the install fails, deploy anyway and report the caveat: nothing in this release regresses without ffmpeg, it simply does not gain the video half. |
|
||||
| `production-pertento-bridge` | — | x86_64, port **9010**. |
|
||||
| `edge-pertento-bridge` | — | x86_64. Node v24 here where the fleet is v22; sharp ships binaries for both and this was not a problem for the install on any other host, but if step 2 fails anywhere it is most likely here. |
|
||||
| `deadpool-rezio-bridge` | — | x86_64, login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `bun install` result, the libvips version step 2 printed, whether ffmpeg
|
||||
was found, and `officer` back online with a moved `restart_time`.
|
||||
|
||||
The two lines worth having in the summary that no previous report needed: **which hosts print a libvips
|
||||
version**, and **which hosts have ffmpeg**. Those are the two facts this release is actually about.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.29.01 — deploy report
|
||||
|
||||
**7 of 7 target hosts succeeded. 1 skipped (`officer-dev`, the host this rollout ran on).**
|
||||
|
||||
Every target ended at `58f50a52`, the tag commit. Tag and `package.json` agreed before anything was
|
||||
touched. No host failed, none was left half-applied, and nothing was reset or discarded.
|
||||
|
||||
## The two facts this release is about
|
||||
|
||||
**Every host prints a libvips version.** `sharp@0.35.4` installed and loaded on all seven, `libvips
|
||||
8.18.6` everywhere — x86_64 and arm64, Node v22 and v24. The plan flagged `edge-pertento-bridge` (Node
|
||||
v24) as the most likely failure; it was not one.
|
||||
|
||||
**Every host now has ffmpeg.** Six already had it. `macbruno` did not, and the plan's per-host `brew
|
||||
install ffmpeg` ran there before the restart and succeeded — so this release ships with no ffmpeg caveat
|
||||
outstanding on any host.
|
||||
|
||||
| Host | HEAD before → after | install | libvips | ffmpeg | officer |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | — | — | **SKIPPED — rollout host** |
|
||||
| `alpha` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 2→3, 401 |
|
||||
| `fofos` | `de425d9d` → `58f50a52` | no-op | 8.18.6 | 6.1.1 (had) | online, rt 25→26, 401 |
|
||||
| `macbookpro` | `16fa8692` → `58f50a52` | 10 pkgs | 8.18.6 | 8.1.2 (had) | online, rt 2→3, 401 :9010 |
|
||||
| `macbruno` | `9c8af9d4` → `58f50a52` | 10 pkgs | 8.18.6 | **9.0.1 (installed)** | online, rt 5→6, 401 |
|
||||
| `production-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 3→4, 401 :9010 |
|
||||
| `edge-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 4→5, 401 |
|
||||
| `deadpool-rezio-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 21→22, 401 |
|
||||
|
||||
Health is `/api/health` on each host's own port, read from its `.env` rather than assumed. `401` is
|
||||
healthy per the plan. `production-pertento-bridge` was checked on 9010, not 9000.
|
||||
|
||||
## Schema
|
||||
|
||||
**Unchanged, and no `bun db:push` was run anywhere.** The plan says so and I verified it rather than
|
||||
taking its word:
|
||||
|
||||
```
|
||||
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
|
||||
```
|
||||
|
||||
returns nothing. No table, column or constraint in this release, so the "check the database by query"
|
||||
rule had nothing to check and no SQL was improvised. Plugin schema lines in `plugins:update` output
|
||||
(`schema: applied in …ms, N table(s) verified`) are that script's own per-plugin verification, not this
|
||||
release.
|
||||
|
||||
## The one deviation, and why
|
||||
|
||||
**On `macbookpro` the standard step order failed, and I ran the same steps in the other order.**
|
||||
|
||||
`bun run plugins:update` exited 1 with:
|
||||
|
||||
```
|
||||
error: Cannot find package 'sharp' from '…/src/servers/api/file-browser/thumbnails.ts'
|
||||
```
|
||||
|
||||
The updater imports the server graph, which now reaches the new `thumbnails.ts`, which imports `sharp`.
|
||||
The standard steps in `prompts/deploy-commander.md` put `plugins:update` **before** the plan's `bun
|
||||
install` — so on a host where sharp is genuinely new, the step cannot resolve a dependency the same
|
||||
release is about to add.
|
||||
|
||||
I ran `bun install` first, then `plugins:update`, which then exited 0. Both steps ran, none was skipped
|
||||
and nothing was composed — only the order changed. I did the same on the four hosts after it. `alpha`
|
||||
and `fofos` had already passed in the documented order because sharp was on disk for reasons of their
|
||||
own: `alpha` via a plugin's declared npm dependencies, `fofos` as the pre-tag test host.
|
||||
|
||||
**This is a finding for whoever writes the next plan, not a fix I made.** Any future release that adds a
|
||||
runtime dependency imported by the server graph hits the same wall in the same place. The ordering lives
|
||||
in the commander prompt's standard steps, and correcting it is a documentation change, which a rollout
|
||||
does not make.
|
||||
|
||||
## What else moved
|
||||
|
||||
`bun run plugins:update` restarts sidecars itself. It reported `sidecar: started` on `alpha` (15/15
|
||||
plugins, `officer-music` re-added), `fofos` (5/5), `macbruno` (1/1), `production-pertento-bridge` (1/1,
|
||||
`officer-gitea`), `edge-pertento-bridge` (2/2, `officer-offscale`) and `deadpool-rezio-bridge` (2/2).
|
||||
`macbookpro` has no plugins installed. So more than `officer` bounced on most hosts — that is the
|
||||
updater's own behaviour, not a restart this rollout asked for. The only restart it issued was `pm2
|
||||
restart officer`, per host, by name.
|
||||
|
||||
`production-pertento-bridge`: all eight non-Officer Pertento processes were still `online` after the
|
||||
restart. Nothing there was named or touched.
|
||||
|
||||
## Notes, not problems
|
||||
|
||||
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
|
||||
cannot touch it. No host had tracked changes; no work was discarded anywhere.
|
||||
- `production-pertento-bridge` prints an nvm warning (`has a globalconfig and/or a prefix setting…`) on
|
||||
every `bun`/`node` invocation. Pre-existing, unrelated to this release, harmless to it.
|
||||
- `macbruno`: `officer`'s PM2 environment already carries `/opt/homebrew/bin`, so the newly installed
|
||||
ffmpeg is on the restarted process's PATH. Verified, not assumed.
|
||||
|
||||
## For the owner
|
||||
|
||||
- **`officer-dev` is deliberately a step behind the fleet.** Its checkout is already at `58f50a52`, but
|
||||
nothing was pulled, installed or restarted on it, and `bun install` has NOT run there — so sharp is
|
||||
not necessarily present for the running process. Restart it deliberately when you are happy with the
|
||||
rest, and run `bun install` first or the thumbnails endpoint will not have its dependency.
|
||||
- The `plugins:update` ordering above is worth fixing in the prompt before the next release that adds a
|
||||
dependency.
|
||||
@@ -0,0 +1,119 @@
|
||||
# 2026.08.29.02 — a plugin can ship a commander
|
||||
|
||||
**This one changes the database.** Two columns, both nullable additions, on a table that already exists.
|
||||
There is no rename in it, nothing is dropped, and no existing row changes — but `db:push` is still not
|
||||
to be trusted on its exit code, so there is a per-host query below and the report must carry its answer
|
||||
for each host.
|
||||
|
||||
No dependency change: `package.json` and `bun.lock` are untouched since `2026.08.29.01`. **There is no
|
||||
`bun install` step in this plan** — which also sidesteps the step-order problem the last rollout found,
|
||||
where the standard `plugins:update` ran before the plan's install.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Plugins can ship commanders.** A plugin with `commanders/<name>/COMMANDER.md` offers one; it appears
|
||||
in Command HQ under a new **From plugins** section, arrives **not running**, and must be configured
|
||||
before it can arm.
|
||||
- **av-club ships the first one** — *Convert on arrival*: watches a folder, converts what lands in it.
|
||||
It reaches each host through the standard `bun run plugins:update` step, not through this plan.
|
||||
- Marketplace, file browser and Convert Image work from earlier today rides along.
|
||||
|
||||
## Schema — CHANGED. `db:push` is required.
|
||||
|
||||
```
|
||||
command_hq_sessions
|
||||
+ plugin text null which plugin's template made this commander
|
||||
+ plugin_config jsonb null the owner's answers to that template's inputs
|
||||
```
|
||||
|
||||
Verified as the whole of it:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.01..deploy/2026.08.29.02 | grep -iE 'schema|/db/|databases'
|
||||
# → src/databases/officer_db/src/command-hq/queries.ts
|
||||
# src/databases/officer_db/src/command-hq/schema.ts
|
||||
```
|
||||
|
||||
`queries.ts` is code, not schema. The only structural change is the two columns above.
|
||||
|
||||
**Both are nullable with no default and no constraint**, so every existing row is valid the moment they
|
||||
exist and there is nothing to backfill. This is the shape `db:push` handles cleanly — there is no
|
||||
create-then-constrain here, and no `--force` is needed or permitted.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
After the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.08.29.02`, `bun run plugins:update`), in the checkout from PM2's
|
||||
`pm_cwd`:
|
||||
|
||||
### 1. Back the database up first
|
||||
|
||||
```bash
|
||||
pg_dump "$(grep -m1 -E '^(OFFICERDEV_)?POSTGRES_URL=' .env | cut -d= -f2-)" > ~/db-backups/officer-$(date +%Y%m%d-%H%M).sql
|
||||
```
|
||||
|
||||
Read the variable name rather than assuming it: **several hosts use `OFFICERDEV_POSTGRES_URL`**, not
|
||||
`POSTGRES_URL`. `mkdir -p ~/db-backups` first if it is not there.
|
||||
|
||||
### 2. Push the schema
|
||||
|
||||
```bash
|
||||
bun db:push < /dev/null
|
||||
```
|
||||
|
||||
**stdin closed**, so it can never sit waiting on a prompt in an unattended run — it fails instead, which
|
||||
is the outcome you want. Expect `[✓] Changes applied` and then the two post-push scripts.
|
||||
|
||||
### 3. VERIFY BY QUERY. The exit code is not the answer.
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { db } = await import('officerdb/db'); const { sql } = await import('drizzle-orm');
|
||||
const r = await db.execute(sql\`select column_name from information_schema.columns
|
||||
where table_name='command_hq_sessions' and column_name in ('plugin','plugin_config') order by column_name\`);
|
||||
console.log([...((r as any).rows ?? r)].map((x:any)=>x.column_name).join(' '));
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
Must print exactly:
|
||||
|
||||
```
|
||||
plugin plugin_config
|
||||
```
|
||||
|
||||
**Put that line in the report, per host.** Not "schema ✓" — the output. A host printing one column, or
|
||||
none, is a **failed host**: report it, move on, and do not attempt a repair you invented.
|
||||
|
||||
### 4. Restart
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only. `plugins:update` has already restarted the plugin sidecars itself.
|
||||
|
||||
### 5. Check 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 both healthy.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. Its schema is already pushed; nothing else is needed. Name it as skipped. |
|
||||
| `alpha` | Nothing special. |
|
||||
| `fofos` | The one that matters this time — `av-club` is installed there and the commander template is the point of the release. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
|
||||
| `production-pertento-bridge` | Port **9010**. |
|
||||
| `edge-pertento-bridge` | Nothing special. |
|
||||
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, whether the backup was written, `db:push` result, **the exact output of
|
||||
the verification query**, and `officer` back online with a moved `restart_time`.
|
||||
|
||||
The line that matters in the summary: **which hosts print `plugin plugin_config`**. That is the only
|
||||
thing this release actually changes about a machine.
|
||||
@@ -0,0 +1,74 @@
|
||||
# 2026.08.29.02 — deploy report
|
||||
|
||||
**7 of 7 target hosts deployed. `officer-dev` skipped by design.** Every host prints
|
||||
`plugin plugin_config` — the two new `command_hq_sessions` columns exist on all eight databases.
|
||||
|
||||
All eight checkouts were at `58f50a52` (the `2026.08.29.01` tag) before this rollout, and all are at
|
||||
`78bf63c2` (`deploy/2026.08.29.02`) now. No host was ahead, none diverged, none was dirty.
|
||||
|
||||
This was the first release under a new watcher loop. The rollout itself behaved as written.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | plugins:update | backup | db:push | verification query | officer | health |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — **SKIPPED** | — | — | — | `plugin plugin_config` | not restarted | — |
|
||||
| `alpha` | `58f50a52` → `78bf63c2` | 15/15, exit 0 | 12,309,974 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (9000) |
|
||||
| `fofos` | `58f50a52` → `78bf63c2` | 5/5, exit 0 | 2,792,097 B | exit 0 | `plugin plugin_config` | 26 → 27 restarts, online | 401 (9000) |
|
||||
| `macbookpro` | `58f50a52` → `78bf63c2` | no plugins installed | 5,179,143 B | exit 0 | `plugin plugin_config` | 3 → 4 restarts, online | 401 (**9010**) |
|
||||
| `macbruno` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 86,480 B | exit 0 | `plugin plugin_config` | 6 → 7 restarts, online | 401 (9000) |
|
||||
| `production-pertento-bridge` | `58f50a52` → `78bf63c2` | 1/1, exit 0 | 187,488 B, **second attempt** | exit 0 | `plugin plugin_config` | 4 → 5 restarts, online | 401 (**9010**) |
|
||||
| `edge-pertento-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 5,916,050 B | exit 0 | `plugin plugin_config` | 5 → 6 restarts, online | 401 (9000) |
|
||||
| `deadpool-rezio-bridge` | `58f50a52` → `78bf63c2` | 2/2, exit 0 | 92,979 B | exit 0 | `plugin plugin_config` | 22 → 23 restarts, online | 401 (9000) |
|
||||
|
||||
`401` is the healthy answer from `/api/health` behind the session gate, as the plan says.
|
||||
|
||||
## What the release was for, confirmed on the host that matters
|
||||
|
||||
`fofos` carries `av-club`, and the commander template it now ships is on disk:
|
||||
`plugins/av-club/commanders/convert-on-arrival/COMMANDER.md`, plugin checkout at `5b2fe03`
|
||||
("Convert on arrival — the first plugin-shipped commander"). The plugin arrived through the standard
|
||||
`bun run plugins:update` step, as the plan predicted — nothing in this plan installed it.
|
||||
|
||||
## What did not go to plan
|
||||
|
||||
**`production-pertento-bridge` failed its backup on the first attempt and the host stopped there.**
|
||||
The plan's `pg_dump` is Ubuntu's 16.15; that host's Postgres is 18.3, and `pg_dump` refuses a newer
|
||||
server outright:
|
||||
|
||||
```
|
||||
pg_dump: error: aborting because of server version mismatch
|
||||
pg_dump: detail: server version: 18.3; pg_dump version: 16.15
|
||||
```
|
||||
|
||||
The host was left correct but half-done: code at the tag, plugins updated, **no `db:push` and no
|
||||
restart**, so the running process was still serving the old code against the old schema. Nothing was
|
||||
broken by stopping there.
|
||||
|
||||
Postgres on that host runs in a docker container (`postgres:18.3-alpine`), so a version-matched client
|
||||
was already present. The backup was retaken with `docker exec postgres pg_dump` — same dump, correct
|
||||
tool, read-only — and the plan then continued unchanged from step 2. Result above.
|
||||
|
||||
**This is a per-host detail worth writing down**, because it will recur on every release whose plan
|
||||
backs the database up: the plan's `pg_dump` line is host-portable only where the client is at least as
|
||||
new as the server. `production-pertento-bridge` is the one host where it is not.
|
||||
|
||||
**`fofos` health read `000` immediately after the restart** and `401` fifteen seconds later. The check
|
||||
was racing a cold start, not finding a fault. The health check now retries for up to thirty seconds
|
||||
before it reports a number.
|
||||
|
||||
**`fofos` has one untracked file**, `scripts/chk.ts`. Noted, not touched — a fast-forward could not have
|
||||
disturbed it.
|
||||
|
||||
## Skipped
|
||||
|
||||
`officer-dev` — the rollout ran on it. Its checkout was already at `78bf63c2` and its database already
|
||||
carries both columns (verified by the same query: `plugin plugin_config`). **Its `officer` process was
|
||||
not restarted**, so it is running the previous build. The owner restarts it deliberately:
|
||||
|
||||
```
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`docs/servers.md` lists nothing under "Deliberately skipped" today, and no host was unreachable —
|
||||
`macbookpro` was awake.
|
||||
@@ -0,0 +1,85 @@
|
||||
# 2026.08.29.03 — the todo board leaves, and a plugin can claim a file type
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.02..deploy/2026.08.29.03 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --stat deploy/2026.08.29.02..deploy/2026.08.29.03 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exist in this plan, and neither should be improvised.**
|
||||
Ten commits, all code.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **`/todos` is gone from the platform.** It is now the `kanban` plugin, in the marketplace. The
|
||||
`todos` permission, the route, the screen and the board were all removed.
|
||||
- **A plugin can claim a file type.** A manifest may declare `fileTypes`; the file browser then offers
|
||||
thumbnails and a full-size render for formats the platform itself cannot read. av-club claims
|
||||
`heic`/`heif`, so on a host with av-club installed, iPhone photographs get pictures instead of file
|
||||
icons and open in the viewer.
|
||||
- Thumbnails retry rather than giving up, and list and grid ask for the same size.
|
||||
- The directory picker moved into the platform so more than one plugin can use it.
|
||||
|
||||
## A behaviour change to expect, and it is deliberate
|
||||
|
||||
**`/todos` now 404s.** Anyone with it bookmarked gets nothing. The board is the `kanban` plugin, which
|
||||
is in the marketplace and installed nowhere by default — that is the intended state, not an oversight.
|
||||
|
||||
`role_permissions` rows naming `todos` are now orphaned. Harmless: absence denies, and the registry no
|
||||
longer has the key. **Do not clean them up as part of this rollout** — it is a data edit nobody asked
|
||||
for, and a row naming a permission that does not exist grants nothing.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the whole rollout this time (dirty check,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.29.03`, `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 listing route, so one call per host is worth it:
|
||||
|
||||
```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.
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here and it is already at this code. Name it as skipped. |
|
||||
| `alpha` | No av-club, so no HEIC support — expected, not a fault. |
|
||||
| `fofos` | **The only host with av-club installed**, and already at this commit and verified: 6 HEICs thumbnail and one renders at full size. Bring it to the tag so its HEAD matches the fleet. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||
| `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` | Did not answer the survey while this plan was written. It may simply have been busy; treat it as an ordinary target and report what you find. |
|
||||
| `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,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.
|
||||
+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
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.27.01",
|
||||
"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",
|
||||
@@ -160,6 +161,7 @@
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sharp": "^0.35.3",
|
||||
"shiki": "^3.22.0",
|
||||
"sonner": "^2.0.7",
|
||||
"sounds": "workspace:*",
|
||||
|
||||
@@ -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}`);
|
||||
@@ -60,14 +60,56 @@ function coreProcesses(): Pm2App[] {
|
||||
const [name, script, args] = line.split('|');
|
||||
if (!name || !script) return fail(`unreadable CORE_PROCESSES entry: "${line}"`);
|
||||
return { name, script, args, cwd: PLATFORM_DIR, watch: false };
|
||||
});
|
||||
})
|
||||
.filter((app) => !DISABLED.has(app.name));
|
||||
}
|
||||
|
||||
/*
|
||||
Core processes this host does not want, from `OFFICER_DISABLED_SIDECARS` in `.env`.
|
||||
|
||||
A core process is core because the platform's own features depend on it — which is why the list is a
|
||||
fixed array and not a setting. But "the platform depends on it" is not "every host runs it": fofos is a
|
||||
1.9 GB machine serving a website, and `officer-opencode` sat there costing 456 MB of that for a harness
|
||||
nobody on it uses. It froze hard twice, with no OOM line in the journal — which is what memory
|
||||
exhaustion looks like when there is not enough headroom left to kill something cleanly.
|
||||
|
||||
Deliberately NOT a database setting. This file is regenerated during a plugin install, so a host that
|
||||
has dropped a process needs it to stay dropped across one. `.env` is read at generation time and is
|
||||
per-host, which is exactly the scope of the decision.
|
||||
|
||||
Naming something the platform genuinely needs will break that host — `officer` itself, or
|
||||
`officer-pty` if anyone uses a terminal. Not validated, because which processes are safe to drop
|
||||
depends on what the host is for, and this script cannot know that.
|
||||
*/
|
||||
const DISABLED = new Set(
|
||||
(process.env.OFFICER_DISABLED_SIDECARS ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
/** Installed AND enabled AND actually ships a sidecar — all three, or it gets no entry. */
|
||||
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. */
|
||||
|
||||
@@ -60,7 +60,17 @@ export async function renderPlatformSkill(): Promise<string> {
|
||||
const out = (s = '') => lines.push(s);
|
||||
|
||||
out('---');
|
||||
out('name: Officer platform');
|
||||
/*
|
||||
`name` is an IDENTIFIER, not a label: lowercase-hyphenated and equal to the directory, per both
|
||||
harnesses' rules. The human name is `title`.
|
||||
|
||||
This generator emitted `name: Officer platform` and was missed when c24859c fixed every skill by
|
||||
hand — so the file on disk was right and running this script put the old shape back. `docs:check`
|
||||
then failed on a line count that nobody could fix by regenerating, which is the worst place for a
|
||||
drift check to point.
|
||||
*/
|
||||
out('name: platform');
|
||||
out('title: Officer platform');
|
||||
// The description is the ENTIRE interface: the agent sees only this before deciding to open the body.
|
||||
// Vague makes a skill useless rather than merely verbose — see skills/load.ts.
|
||||
out('description: >-');
|
||||
|
||||
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
-157
@@ -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,135 +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 are two, and they are not the same thing. `platform/plugins/` holds what this machine has
|
||||
INSTALLED — updated by `plugins:update`, which is a git pull, and therefore always at whatever version
|
||||
was last installed. `officer-dev/plugins/` holds every repo in the org, cloned for working on, and is
|
||||
where a release is actually made.
|
||||
|
||||
Publishing from the install directory 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.
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -31,9 +31,14 @@ import { join } from 'node:path';
|
||||
/*
|
||||
Which plugins directory to check.
|
||||
|
||||
There are two as of 2026-08-27: `platform/plugins/` — what this machine has INSTALLED — and
|
||||
`officer-dev/plugins/`, every repo in the org cloned side by side for working on. They hold different
|
||||
sets, so the check has to be pointable at either rather than assuming the one it happens to sit next to.
|
||||
`platform/plugins/` is the one that exists. There was briefly a second — `officer-dev/plugins/`, meant
|
||||
to hold every repo in the org side by side — and it was removed on 2026-08-28 once it held nothing but
|
||||
symlinks into this one. A plugin cannot live outside the platform anyway: Bun resolves a symlink to its
|
||||
real path, so `@@/…` and every relative import fail from there. Tested, including
|
||||
`--preserve-symlinks`, which Bun accepts and ignores.
|
||||
|
||||
`PLUGINS_DIR` stays overridable for a checkout somewhere else, which costs nothing and is the only
|
||||
reason this is not a constant.
|
||||
*/
|
||||
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? join(import.meta.dir, '..', 'plugins');
|
||||
const HOST = 'git@gitea.officer.dev';
|
||||
|
||||
@@ -82,6 +82,13 @@ LAST_SKIPPED=()
|
||||
# compiles a native module needs it, but it is the one to move out first if that
|
||||
# ever bites.
|
||||
#
|
||||
# ffmpeg is not a convenience here, it is a hard dependency of the file browser.
|
||||
# Subtitles, audio extraction, audio track listing and video thumbnails all shell
|
||||
# out to it, so on a machine without it those features fail at the moment somebody
|
||||
# opens a folder — which reads as Officer being broken rather than as a package
|
||||
# being absent. It was missing from this list until 2026-08-29 and the features
|
||||
# worked anyway, because every machine that had been set up by hand already had it.
|
||||
#
|
||||
# inotify-tools is `inotifywait`, and it is what lets a COMMANDER react the
|
||||
# moment a file lands instead of polling for it. Without it a watcher can only
|
||||
# ask "is there anything new?" on a timer, which is both slower and more work —
|
||||
@@ -109,24 +116,24 @@ pkgs_core() {
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
apt-transport-https lsb-release software-properties-common \
|
||||
wget zip brotli build-essential python3 btop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils fail2ban unattended-upgrades
|
||||
inotify-tools poppler-utils ffmpeg fail2ban unattended-upgrades
|
||||
;;
|
||||
pacman)
|
||||
echo curl ca-certificates gnupg git jq unzip \
|
||||
wget zip brotli base-devel python btop tree tmux ripgrep fd net-tools eza \
|
||||
inotify-tools poppler fail2ban
|
||||
inotify-tools poppler ffmpeg fail2ban
|
||||
;;
|
||||
dnf)
|
||||
echo curl ca-certificates gnupg2 git jq unzip \
|
||||
wget zip brotli python3 btop tree tmux ripgrep fd-find net-tools eza \
|
||||
inotify-tools poppler-utils fail2ban
|
||||
inotify-tools poppler-utils ffmpeg fail2ban
|
||||
;;
|
||||
brew)
|
||||
# curl, unzip and the TLS roots ship with macOS; the compilers come from
|
||||
# the Xcode command line tools, which is not a formula — see xcode_clt_*.
|
||||
# brotli is here because macOS ships the library but not the CLI.
|
||||
#
|
||||
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler
|
||||
echo gnupg git jq wget brotli btop tree ripgrep fd eza poppler ffmpeg
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
@@ -405,6 +405,7 @@ if ! skip; then
|
||||
info "Dependencies — bun install, as ${USERNAME}"
|
||||
echo " node_modules: $(deps_installed && echo present || echo 'not there')"
|
||||
echo " node-pty: $(node_pty_built && echo built || echo 'not built')"
|
||||
echo " sharp: $(sharp_loads && echo "loads (libvips $(sharp_version))" || echo 'does not load')"
|
||||
echo ""
|
||||
echo " The lockfile is frozen: bun resolves from bun.lock and nothing else,"
|
||||
echo " so a package.json that disagrees with it fails rather than quietly"
|
||||
@@ -412,12 +413,29 @@ if ! skip; then
|
||||
echo ""
|
||||
echo " node-pty has no Linux prebuild, so this compiles it from source"
|
||||
echo " every time — which is what build-essential and python3 are for."
|
||||
echo ""
|
||||
echo " sharp is the opposite problem: it does not compile, it downloads a"
|
||||
echo " libvips binary for this exact platform and architecture. So the"
|
||||
echo " install can succeed and the module still not load, and the way that"
|
||||
echo " shows up is a file browser drawing generic icons over a folder of"
|
||||
echo " photographs. Loading it is the only check worth running."
|
||||
|
||||
if deps_installed && node_pty_built; then
|
||||
ok "already installed, and node-pty is built"
|
||||
if deps_installed && node_pty_built && sharp_loads; then
|
||||
ok "already installed; node-pty built and sharp loads"
|
||||
SUMMARY+=("Dependencies: already installed")
|
||||
elif confirm "Install them?"; then
|
||||
if install_deps; then
|
||||
# Reported separately from node-pty rather than as one "native modules"
|
||||
# verdict: they fail for different reasons and want different fixes, and a
|
||||
# combined line sends somebody to rebuild the wrong one.
|
||||
if ! sharp_loads; then
|
||||
warn "installed, but sharp does not load"
|
||||
echo " Image thumbnails in the file browser will silently fall back to"
|
||||
echo " file icons. Usually the platform-specific binary did not resolve:"
|
||||
echo " cd $(platform_dir) && bun install --force"
|
||||
ERRORS+=("Dependencies: sharp does not load")
|
||||
fi
|
||||
|
||||
if node_pty_built; then
|
||||
ok "installed, node-pty built"
|
||||
SUMMARY+=("Dependencies: installed")
|
||||
|
||||
@@ -95,4 +95,27 @@ deps_installed() { [[ -d "$(platform_dir)/node_modules" ]]; }
|
||||
# would not start.
|
||||
node_pty_built() { compgen -G "$(platform_dir)/node_modules/node-pty/build/Release/*.node" >/dev/null 2>&1; }
|
||||
|
||||
# sharp is libvips, and it arrives as a PREBUILT BINARY chosen by platform and
|
||||
# architecture — @img/sharp-linux-x64 and its libvips, or the arm64 pair, or the
|
||||
# musl ones. bun picks them from the lockfile's optional dependencies.
|
||||
#
|
||||
# Which means the install can succeed and the module can still be unusable: a
|
||||
# lockfile written on x64 carries the x64 binaries, and on a machine where the
|
||||
# right optional package did not resolve, `import('sharp')` throws at the first
|
||||
# call rather than at install time. Same failure shape as node-pty above, and the
|
||||
# same reason to check it here — the symptom otherwise is a file browser drawing
|
||||
# generic icons over a folder of photographs, which nobody reads as "a native
|
||||
# module is missing".
|
||||
#
|
||||
# Loading it is the only test worth running. A directory being present proves
|
||||
# nothing; sharp's own `versions.vips` proves libvips is there and bound.
|
||||
sharp_loads() {
|
||||
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => { if (!m.default.versions.vips) process.exit(1) }).catch(() => process.exit(1))\"" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# What libvips sharp found, for the report. Empty when it does not load at all.
|
||||
sharp_version() {
|
||||
as_owner "cd '$(platform_dir)' && bun -e \"import('sharp').then(m => console.log(m.default.versions.vips)).catch(() => {})\"" 2>/dev/null | tr -d '\r'
|
||||
}
|
||||
|
||||
install_deps() { as_owner "cd '$(platform_dir)' && bun install 2>&1"; }
|
||||
|
||||
@@ -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,27 +97,40 @@ 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 />} />
|
||||
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
|
||||
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
|
||||
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
|
||||
<Route path="/todos" element={<Dashboard.TodosScreen />} />
|
||||
<Route path="/bots" element={<Dashboard.BotsScreen />} />
|
||||
{/* Route pair: `/bots` is the new-bot state, `/bots/:sessionId` is one of them. */}
|
||||
<Route path="/bots/:sessionId" element={<Dashboard.BotsScreen />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
|
||||
import { ImpersonationBanner, useDock, usePanelFullscreen, useChatNotifications } from 'officerdev';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||
@@ -46,6 +46,9 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
|
||||
return (
|
||||
<div className="relative flex h-dvh flex-col overflow-hidden outline-none inset-0">
|
||||
{/* ABOVE the header, and outside everything that can be hidden. A fullscreen panel hides the header;
|
||||
it must not be able to hide the fact that you are somebody else. Renders nothing when you are not. */}
|
||||
<ImpersonationBanner />
|
||||
<Header dockItems={visibleItems} hidden={panelFullscreen} />
|
||||
{/* Outside the content region on purpose, like the header and the dock: whatever a screen is doing
|
||||
— maximised panel, error boundary, mid-navigation — ctrl-space still gets you off it. */}
|
||||
|
||||
@@ -109,10 +109,16 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
|
||||
{item.label}
|
||||
</span>
|
||||
<div
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-xl overflow-hidden flex items-center justify-center transition-all"
|
||||
style={{
|
||||
// `bare` keeps the box — it is what sizes the row and carries the glow — and simply
|
||||
// does not paint it.
|
||||
//
|
||||
// `overflow-hidden` is what makes the ROUNDING apply to artwork. A `badge` tile insets
|
||||
// its glyph, so the corners were never reached and nothing here looked wrong for as
|
||||
// long as every plugin used one. `bare` fills the box edge to edge, so a logo on an
|
||||
// opaque background kept its own square corners and sat in the dock as the one tile
|
||||
// that had not been told the shape.
|
||||
background: item.tile === 'bare' ? 'transparent' : item.color,
|
||||
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
|
||||
}}
|
||||
|
||||
@@ -143,7 +143,10 @@ const Row = ({
|
||||
it put a coloured pane behind Calendar, Contacts and every plugin logo.
|
||||
*/}
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md"
|
||||
// `overflow-hidden` for the same reason as the dock: `bare` artwork fills the box, so without it
|
||||
// the rounding is drawn and then painted over. The two have to agree — the same plugin appears in
|
||||
// both, a few pixels apart.
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-md"
|
||||
style={{
|
||||
background: item.tile === 'bare' ? 'transparent' : (item.color ?? 'transparent'),
|
||||
// No colour at all is a settings row: keep the muted glyph rather than inventing a swatch.
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { startImpersonation } from 'officerdev';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Crown, Trash2, Loader2, KeyRound, RotateCcw, Copy, SquareTerminal as TerminalIcon } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
Crown,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
SquareTerminal as TerminalIcon,
|
||||
Trash2,
|
||||
UserCog,
|
||||
} from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
@@ -128,6 +138,25 @@ export const UsersSection = () => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign in as this person, for an hour.
|
||||
*
|
||||
* No confirmation dialog, deliberately: it is reversible from the banner in one click, it destroys
|
||||
* nothing, and it is a support action taken while somebody is waiting. The dialogs beside it guard a
|
||||
* new password and a deletion, which are neither.
|
||||
*/
|
||||
const impersonate = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
try {
|
||||
const { token } = await client.post<{ token: string }>(`/users/${user.id}/impersonate`, {});
|
||||
// Swaps the token and reloads — nothing after this line runs.
|
||||
startImpersonation(token);
|
||||
} catch (ex) {
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not sign in as that user');
|
||||
setPendingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (user: ManagedUser) => {
|
||||
setPendingId(user.id);
|
||||
setConfirmDelete(null);
|
||||
@@ -263,6 +292,22 @@ export const UsersSection = () => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Not for the owner: impersonating yourself is refused by the route, and the row that
|
||||
offers it would be the one row where it cannot work. */}
|
||||
{!user.isOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground"
|
||||
disabled={busy}
|
||||
aria-label={`Sign in as ${user.email}`}
|
||||
title="Sign in as this user for one hour"
|
||||
onClick={() => void impersonate(user)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <UserCog className="h-4 w-4" />}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
import { WorkspaceView } from 'officerdev';
|
||||
import { useDashboardState } from 'state/useDashboardState';
|
||||
import { defaultLayout } from './defaultLayout';
|
||||
|
||||
// /todos — the Kanban board over the repo's own `todo/` tree (one file per item, todo/README.md).
|
||||
// Selection and the theme filter are `?selected=` / `?theme=`, read by the panel itself.
|
||||
export const TodosScreen = () => {
|
||||
const workspace = useDashboardState<LayoutNode>('screens/todos', defaultLayout);
|
||||
|
||||
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['todo-board'], fallback: 'todo-board' }} />;
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// One panel: the board carries its own filter bar and detail pane, driven by the URL.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'todo-board',
|
||||
appType: 'todo-board',
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './TodosScreen';
|
||||
@@ -21,5 +21,4 @@ export * from './Terminal';
|
||||
export * from './Jobs';
|
||||
export * from './QrTransfer';
|
||||
export * from './SendOff';
|
||||
export * from './Todos';
|
||||
export * from './Bots';
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -63,6 +63,8 @@ export type CommandHqSessionRow = {
|
||||
/** What the owner called it. Null for rows filed before the column existed — fall back to the transcript. */
|
||||
name: string | null;
|
||||
categoryId: number | null;
|
||||
plugin: string | null;
|
||||
pluginConfig: Record<string, string> | null;
|
||||
watcherArmed: boolean;
|
||||
watcherTaskId: string | null;
|
||||
watcherArmedAt: Date | null;
|
||||
@@ -78,6 +80,8 @@ const SESSION_ROW_COLUMNS = {
|
||||
referencePaths: commandHqSessions.referencePaths,
|
||||
name: commandHqSessions.name,
|
||||
categoryId: commandHqSessions.categoryId,
|
||||
plugin: commandHqSessions.plugin,
|
||||
pluginConfig: commandHqSessions.pluginConfig,
|
||||
watcherArmed: commandHqSessions.watcherArmed,
|
||||
watcherTaskId: commandHqSessions.watcherTaskId,
|
||||
watcherArmedAt: commandHqSessions.watcherArmedAt,
|
||||
@@ -85,7 +89,16 @@ const SESSION_ROW_COLUMNS = {
|
||||
createdAt: commandHqSessions.createdAt,
|
||||
};
|
||||
|
||||
export type NewCommander = { userId: number; name: string; referencePath: string; categoryId?: number | null };
|
||||
export type NewCommander = {
|
||||
userId: number;
|
||||
name: string;
|
||||
referencePath: string;
|
||||
categoryId?: number | null;
|
||||
/** The plugin whose template this was made from, or null for one somebody made themselves. */
|
||||
plugin?: string | null;
|
||||
/** The owner's answers to that template's inputs. Null when there was no template. */
|
||||
pluginConfig?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a commander row — no transcript, no directory. The directory is the CALLER's job
|
||||
@@ -109,6 +122,8 @@ export async function createCommander(input: NewCommander): Promise<CommandHqSes
|
||||
referencePaths: [input.referencePath],
|
||||
name: input.name,
|
||||
categoryId: input.categoryId ?? null,
|
||||
plugin: input.plugin ?? null,
|
||||
pluginConfig: input.pluginConfig ?? null,
|
||||
})
|
||||
.returning({ id: commandHqSessions.id });
|
||||
const [row] = await tx
|
||||
@@ -155,9 +170,12 @@ export async function getCommandHqSessionById(userId: number, id: number): Promi
|
||||
export async function listCommandHqSessions(
|
||||
userId: number,
|
||||
categoryId?: number | null,
|
||||
/** Instead of a category: only commanders shipped by this plugin. The two never combine. */
|
||||
plugin?: string,
|
||||
): Promise<CommandHqSessionRow[]> {
|
||||
const where =
|
||||
categoryId === undefined
|
||||
const where = plugin
|
||||
? and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.plugin, plugin))
|
||||
: categoryId === undefined
|
||||
? eq(commandHqSessions.userId, userId)
|
||||
: categoryId === null
|
||||
? and(eq(commandHqSessions.userId, userId), isCategoryNull())
|
||||
@@ -257,6 +275,33 @@ export async function getCommandHqSession(userId: number, sessionId: string): Pr
|
||||
* longer the current one.
|
||||
*/
|
||||
export async function armCommandHqWatcher(userId: number, sessionId: string, taskId: string): Promise<boolean> {
|
||||
/*
|
||||
A commander made from a plugin's template cannot arm until it has been configured.
|
||||
|
||||
Enforced HERE rather than in a route because arming is not a button: it happens when the agent
|
||||
starts a tracked background job, and the platform notices. A check anywhere else would be a check
|
||||
the agent could route around simply by starting its loop, which is exactly what it does.
|
||||
|
||||
Creating a half-configured commander stays fine and useful — it exists, it is visible, and it can be
|
||||
finished later. It is ARMING one that would run somebody's disk against a blank setting.
|
||||
|
||||
A row with no template is unaffected: `plugin` is null and this returns immediately.
|
||||
*/
|
||||
const [existing] = await db
|
||||
.select({ plugin: commandHqSessions.plugin, pluginConfig: commandHqSessions.pluginConfig })
|
||||
.from(commandHqSessions)
|
||||
.where(and(eq(commandHqSessions.userId, userId), eq(commandHqSessions.sessionId, sessionId)))
|
||||
.limit(1);
|
||||
|
||||
if (existing?.plugin) {
|
||||
// Only the presence of answers is checked here, not which ones are required — that needs the
|
||||
// template, which is a filesystem read and does not belong in the query layer. The route that
|
||||
// creates the commander writes every input it was given, so an empty object means nothing was
|
||||
// configured at all, which is the case worth stopping.
|
||||
const answered = Object.values(existing.pluginConfig ?? {}).filter((v) => String(v ?? '').trim());
|
||||
if (answered.length === 0) return false;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.update(commandHqSessions)
|
||||
.set({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
serial,
|
||||
text,
|
||||
@@ -117,10 +118,7 @@ export const commandHqSessions = pgTable(
|
||||
* its own, and an array supports appending in place (`array_append`) without reading the whole column
|
||||
* back to modify it — `jsonb` would buy nothing here but a heavier read-modify-write for the same list.
|
||||
*/
|
||||
referencePaths: text('reference_paths')
|
||||
.array()
|
||||
.notNull()
|
||||
.default([]),
|
||||
referencePaths: text('reference_paths').array().notNull().default([]),
|
||||
/**
|
||||
* What the owner called it, held here rather than only in the transcript's summary record.
|
||||
*
|
||||
@@ -137,6 +135,29 @@ export const commandHqSessions = pgTable(
|
||||
categoryId: integer('category_id').references(() => commandHqCategories.id, { onDelete: 'set null' }),
|
||||
/** Should the platform keep re-arming this commander's watcher on every fire? See the file docstring. */
|
||||
watcherArmed: boolean('watcher_armed').notNull().default(false),
|
||||
|
||||
/**
|
||||
* The plugin that shipped this commander's template, or null for one somebody made themselves.
|
||||
*
|
||||
* A COLUMN rather than a reserved category name, for the reason tasks carry the same field:
|
||||
* provenance should be structural. A category called "av-club" is a string an owner can rename,
|
||||
* merge or delete, and the day they do, a plugin's commanders quietly become ordinary ones —
|
||||
* with nothing on disk saying otherwise.
|
||||
*
|
||||
* It also survives the plugin being uninstalled, which is the point: the commander is still there,
|
||||
* still in its directory, and the UI can say what it came from rather than pretending it was
|
||||
* always hand-made.
|
||||
*/
|
||||
plugin: text('plugin'),
|
||||
|
||||
/**
|
||||
* The owner's answers to the template's `inputs`, or null when there was no template.
|
||||
*
|
||||
* Held here rather than only in the generated watcher script because the form has to be
|
||||
* re-openable: somebody changing which directory is watched should see what it is watching now,
|
||||
* not an empty form. The script is generated FROM this, so this is the source of truth.
|
||||
*/
|
||||
pluginConfig: jsonb('plugin_config').$type<Record<string, string>>(),
|
||||
/**
|
||||
* The task id of the watcher currently believed live for this commander — set when armed (first arm,
|
||||
* and again on every automatic re-arm), read to correlate a `task:notification` back to "this is the
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { capabilitySources } from '../../capability-sources';
|
||||
|
||||
// Bun's own YAML, the same one `task-files.ts` uses. Not the `yaml` package — it is not a dependency,
|
||||
// and adding one to read four lines of frontmatter is not a trade worth making.
|
||||
const YAML = (Bun as unknown as { YAML: { parse(input: string): unknown } }).YAML;
|
||||
|
||||
/**
|
||||
* Commanders a plugin ships, ready to be created but not running.
|
||||
*
|
||||
* ── Presence is the declaration ──
|
||||
*
|
||||
* A plugin with a `commanders/<name>/COMMANDER.md` offers one. Nothing is added to `manifest.ts`, for
|
||||
* the same reason `tasks/` and `skills/` are not declared there: the directory already says it, and a
|
||||
* second place to say it is a second place for the two to disagree.
|
||||
*
|
||||
* Read IN PLACE, and only from installed-and-enabled plugins — `capabilitySources` handles both. So a
|
||||
* disabled plugin stops offering its commander, and `plugins:update` changes the template without
|
||||
* anything having to be republished.
|
||||
*
|
||||
* ── A template is not a commander ──
|
||||
*
|
||||
* What is here is an OFFER. Nothing runs, nothing is scheduled, and no directory exists until the
|
||||
* owner creates one from it and fills in the inputs. That is the whole shape the owner asked for:
|
||||
* a plugin may propose recurring work; only a person may start it.
|
||||
*
|
||||
* ── Why the inputs reuse TASK.md's vocabulary ──
|
||||
*
|
||||
* `inputs:` here is the same structure a task declares, so the same form renderer draws it and the
|
||||
* same validation applies. A commander asking "which folder, which extensions, what output" is asking
|
||||
* exactly what a task asks, and inventing a second schema for it would mean two form renderers to
|
||||
* keep in step for no gain.
|
||||
*/
|
||||
|
||||
export type CommanderInput = {
|
||||
type: string;
|
||||
description?: string;
|
||||
default?: string;
|
||||
options?: string[];
|
||||
/** Unset and required means the commander cannot arm. See `missingInputs`. */
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type PluginCommanderTemplate = {
|
||||
/** Directory name, unique within its plugin — `heic-to-webp`. */
|
||||
dirName: string;
|
||||
/** The plugin that ships it. Never null: an owner's own commanders do not come from a template. */
|
||||
plugin: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** What it watches, in a sentence, for the card. */
|
||||
watches?: string;
|
||||
inputs: Record<string, CommanderInput>;
|
||||
/** The prose that becomes the commander's `CLAUDE.md`. */
|
||||
body: string;
|
||||
/** Absolute path to the template directory, so the watcher script can be read at creation. */
|
||||
dir: string;
|
||||
};
|
||||
|
||||
function parse(raw: string): { fm: Record<string, unknown>; body: string } {
|
||||
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||
if (!match) return { fm: {}, body: raw };
|
||||
try {
|
||||
return { fm: (YAML.parse(match[1]!) as Record<string, unknown>) ?? {}, body: match[2]! };
|
||||
} catch {
|
||||
// A template with unparseable frontmatter is skipped by the caller rather than crashing the list —
|
||||
// one plugin's bad YAML must not take away every other plugin's commanders.
|
||||
return { fm: {}, body: match[2]! };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPluginCommanders(): Promise<PluginCommanderTemplate[]> {
|
||||
const sources = await capabilitySources('commanders');
|
||||
const found: PluginCommanderTemplate[] = [];
|
||||
|
||||
for (const source of sources) {
|
||||
// Only plugins offer these. The owner's own `capabilities/commanders/` is not a thing that exists:
|
||||
// a commander somebody makes themselves is made in the UI, not from a template on disk.
|
||||
if (!source.plugin) continue;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(source.dir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue; // most plugins ship none — ordinary, not a fault
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const dir = join(source.dir, entry.name);
|
||||
const file = Bun.file(join(dir, 'COMMANDER.md'));
|
||||
if (!(await file.exists())) continue;
|
||||
|
||||
const { fm, body } = parse(await file.text());
|
||||
if (!fm.name) continue; // no name means the frontmatter did not parse — skip it quietly
|
||||
|
||||
found.push({
|
||||
dirName: entry.name,
|
||||
plugin: source.plugin,
|
||||
name: String(fm.name),
|
||||
description: String(fm.description ?? ''),
|
||||
watches: fm.watches ? String(fm.watches) : undefined,
|
||||
inputs: (fm.inputs as Record<string, CommanderInput>) ?? {},
|
||||
body,
|
||||
dir,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return found.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
export async function getPluginCommander(plugin: string, dirName: string): Promise<PluginCommanderTemplate | null> {
|
||||
// Both halves reach this from a request body and are joined onto a path below.
|
||||
const unsafe = (v: string) => !v || v.includes('/') || v.includes('\\') || v === '.' || v === '..';
|
||||
if (unsafe(plugin) || unsafe(dirName)) return null;
|
||||
|
||||
const all = await listPluginCommanders();
|
||||
return all.find((t) => t.plugin === plugin && t.dirName === dirName) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which required inputs have no answer.
|
||||
*
|
||||
* This is the "configure before running" rule, and it is enforced at ARM time rather than at creation.
|
||||
* Creating a half-configured commander is fine and useful — it exists, it is visible, and it can be
|
||||
* finished later. Arming one is what would run somebody's disk against a blank setting.
|
||||
*/
|
||||
export function missingInputs(
|
||||
inputs: Record<string, CommanderInput>,
|
||||
config: Record<string, string> | null | undefined,
|
||||
): string[] {
|
||||
return Object.entries(inputs)
|
||||
.filter(([key, spec]) => spec.required && !(config?.[key] ?? spec.default ?? '').trim())
|
||||
.map(([key]) => key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a created commander needs on disk beyond its `CLAUDE.md`.
|
||||
*
|
||||
* ── `config.env`, not a JSON file ──
|
||||
*
|
||||
* The watcher is a shell script and this is what it sources. A JSON config would mean the script
|
||||
* parsing JSON in bash, which is either `jq` (another dependency for four values) or something
|
||||
* hand-rolled that breaks on the first path with a space in it. The database row remains the source
|
||||
* of truth; this is a projection of it that bash can read.
|
||||
*
|
||||
* ── Every value is quoted, and that is not decoration ──
|
||||
*
|
||||
* `WATCH_DIR` is a path the owner typed, and this fleet has directories called `ABOUT US` and
|
||||
* `70´S`. Unquoted, the first space ends the assignment and the watcher silently watches the wrong
|
||||
* folder — or, with `rm` involved downstream, the wrong folder's contents.
|
||||
*/
|
||||
export function configEnv(
|
||||
inputs: Record<string, CommanderInput>,
|
||||
config: Record<string, string> | null | undefined,
|
||||
): string {
|
||||
const lines = ['# Written by Officer when this commander was created. Edit through the UI, not here.'];
|
||||
|
||||
for (const [key, spec] of Object.entries(inputs)) {
|
||||
const value = config?.[key] ?? spec.default ?? '';
|
||||
// Single quotes, with any embedded single quote closed and re-opened around an escaped one — the
|
||||
// standard shell trick, and the only form that is safe for arbitrary text.
|
||||
const quoted = `'${String(value).replace(/'/g, `'\\''`)}'`;
|
||||
lines.push(`${key.toUpperCase()}=${quoted}`);
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { Context } from 'hono';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
import { checkBudget } from '../sub-agents/budget';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
import { toShellUsername, getCommanderDir } from '@@/data-path';
|
||||
import { toShellUsername, getCommanderDir, getCommanderStateDir } from '@@/data-path';
|
||||
import {
|
||||
forgetChatSessions,
|
||||
getChatEventsSince,
|
||||
@@ -17,6 +18,7 @@ import * as sidecar from '@@/sidecar-registry';
|
||||
import { findSessionKeyByClaudeSession } from '@@/sidecar/claude/state';
|
||||
import { sessionKeyForTranscript } from '../chat/websocket';
|
||||
import { listSpawnedSessions, loopPidPath, spawnedRecordPath, stopCommanderProcesses, liveLoopPid } from './spawned';
|
||||
import { configEnv, getPluginCommander, listPluginCommanders, missingInputs } from './plugin-commanders';
|
||||
import { createCommanderDirectory } from './directory';
|
||||
import { commanderKeyName } from './credential';
|
||||
import type { ServerMessage } from '../chat/types';
|
||||
@@ -39,9 +41,12 @@ import {
|
||||
setCommanderSessionId,
|
||||
listCommandHqSessions,
|
||||
getCommandHqSession,
|
||||
getCommandHqSessionById,
|
||||
setCommandHqSessionCategory,
|
||||
purgeCommandHqSessions,
|
||||
deleteCommandHqSession,
|
||||
armCommandHqWatcher,
|
||||
recordCommandHqWatcherFired,
|
||||
disarmCommandHqWatcher,
|
||||
getCommandHqCostSummary,
|
||||
listCommandHqTurns,
|
||||
@@ -201,6 +206,17 @@ type CommanderSummary = ClaudeSessionSummary & {
|
||||
watcherLastFiredAt: Date | null;
|
||||
/** The running total — every turn this commander has ever cost, summed. See `GET .../turns` for the raw series. */
|
||||
cost: CommandHqCostSummary;
|
||||
/** Where it came from, when a plugin's template made it. Null for one somebody wrote themselves. */
|
||||
plugin?: string | null;
|
||||
/** The owner's answers to that template's inputs, so the card can say what it is set to watch. */
|
||||
pluginConfig?: Record<string, string> | null;
|
||||
/**
|
||||
* Created, never opened — so there is no transcript behind it and its id is not a session id.
|
||||
*
|
||||
* Only ever true for a plugin's commander. A hand-made row without a transcript is one whose
|
||||
* conversation failed to start, and the design deliberately refuses to draw a tombstone for it.
|
||||
*/
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
async function isRunning(sessionId: string): Promise<boolean> {
|
||||
@@ -222,7 +238,15 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
const categoryParam = ctx.req.query('category');
|
||||
const categoryId = categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
`plugin:<appName>` is a filter on provenance, not a category, and is parsed BEFORE the numeric branch.
|
||||
Without this it reached `Number('plugin:av-club')` — NaN — and the query compared `category_id = NaN`,
|
||||
which matches nothing and looks exactly like "this plugin has no commanders".
|
||||
*/
|
||||
const pluginFilter = categoryParam?.startsWith('plugin:') ? categoryParam.slice('plugin:'.length) : undefined;
|
||||
const categoryId =
|
||||
pluginFilter || categoryParam === undefined ? undefined : categoryParam === 'none' ? null : Number(categoryParam);
|
||||
|
||||
/*
|
||||
Each commander's transcript is loaded BY ID, not found by scanning one directory — a commander's own
|
||||
@@ -235,7 +259,7 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
reason: there is no transcript to summarise. Filtered out before the lookup rather than after, so
|
||||
`loadClaudeSessionSummaryById` is never asked about an id that cannot exist.
|
||||
*/
|
||||
const rows = await listCommandHqSessions(user.id, categoryId);
|
||||
const rows = await listCommandHqSessions(user.id, categoryId, pluginFilter);
|
||||
const withTranscript = rows.filter((row) => row.sessionId !== null);
|
||||
const summaries = withTranscript
|
||||
.map((row) => loadClaudeSessionSummaryById(who, row.sessionId!))
|
||||
@@ -258,6 +282,39 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
),
|
||||
]);
|
||||
|
||||
/*
|
||||
A commander CREATED but never opened has no transcript, and every branch above drops it.
|
||||
|
||||
That was right when the only way to get a row was to start a conversation. It is wrong now: a
|
||||
commander made from a plugin's template arrives deliberately not running, and dropping it means the
|
||||
thing somebody just created is invisible — with a toast saying it worked. So a row with a `plugin`
|
||||
and no session is projected from the ROW alone, with the zeroes that are actually true of it.
|
||||
|
||||
Ordinary commanders keep the old behaviour. A hand-made row with no transcript is a row whose
|
||||
conversation failed to start, and a tombstone for it is what the design deliberately refused.
|
||||
*/
|
||||
const pending = rows
|
||||
.filter((row) => row.sessionId === null && row.plugin)
|
||||
.map(
|
||||
(row) =>
|
||||
({
|
||||
id: `pending-${row.id}`,
|
||||
title: row.name ?? 'Commander',
|
||||
dirName: row.dirName,
|
||||
categoryId: row.categoryId,
|
||||
tagIds: [],
|
||||
running: false,
|
||||
watcherArmed: false,
|
||||
watcherArmedAt: null,
|
||||
watcherStoppedAt: null,
|
||||
watcherLastFiredAt: null,
|
||||
cost: { turnCount: 0, inputTokens: 0, outputTokens: 0, totalUsd: 0 },
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
pending: true,
|
||||
}) as unknown as CommanderSummary,
|
||||
);
|
||||
|
||||
const commanders: CommanderSummary[] = filed
|
||||
.map((row) => ({
|
||||
...liveById.get(row.sessionId!)!,
|
||||
@@ -277,6 +334,10 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
// grid offered nothing to stop — which is the one thing this column exists to make possible. Keyed
|
||||
// on `dirName`, not the transcript id: the pid file lives in this commander's OWN `state/`, not
|
||||
// beside the transcript.
|
||||
// Provenance, so the UI can put a plugin's commanders in their own section rather than inferring
|
||||
// it from a category name somebody can rename.
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
// Whether the LOOP is alive right now, separate from `running` (an agent turn in progress) and from
|
||||
// `watcherArmed` (which is also true for a row that says armed but whose loop has gone).
|
||||
@@ -288,7 +349,9 @@ commandHqRouter.get('/commanders', async (ctx) => {
|
||||
}))
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||||
|
||||
return ctx.json({ commanders });
|
||||
// Pending ones FIRST: a commander that was just created and needs a decision is the thing somebody came
|
||||
// to the screen for, and sorting it by `updatedAt` among running ones would bury it on a busy account.
|
||||
return ctx.json({ commanders: [...pending, ...commanders] });
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -463,6 +526,8 @@ commandHqRouter.get('/commanders/:sessionId', async (ctx) => {
|
||||
categoryId: row.categoryId,
|
||||
tagIds: tagsByRow.get(row.id) ?? [],
|
||||
running,
|
||||
plugin: row.plugin,
|
||||
pluginConfig: row.pluginConfig,
|
||||
watcherArmed: row.watcherArmed || liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherRunning: liveLoopPid(who.email, row.dirName) !== null,
|
||||
watcherArmedAt: row.watcherArmedAt,
|
||||
@@ -525,6 +590,21 @@ commandHqRouter.get('/commanders/:sessionId/fires', async (ctx) => {
|
||||
* design removes — a client that has to remember to follow through, on a value the server already knows
|
||||
* first.
|
||||
*/
|
||||
/**
|
||||
* What the installed plugins OFFER. Not commanders — templates for them.
|
||||
*
|
||||
* Separate from `GET /commanders`, which lists what exists. Merging the two would put a thing that
|
||||
* runs and a thing that could be made in one list, distinguished by a flag, and every consumer would
|
||||
* have to remember which it was holding.
|
||||
*/
|
||||
commandHqRouter.get('/plugin-commanders', async (ctx) => {
|
||||
const templates = await listPluginCommanders();
|
||||
// The body is the commander's future CLAUDE.md and can be pages long; the list does not need it.
|
||||
return ctx.json({
|
||||
templates: templates.map(({ body: _body, dir: _dir, ...rest }) => rest),
|
||||
});
|
||||
});
|
||||
|
||||
commandHqRouter.post('/commanders', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const who = await commandHqIdentity(user);
|
||||
@@ -535,6 +615,11 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
/** A chat context to watch, instead of a path — see below. */
|
||||
context?: string;
|
||||
contextId?: string;
|
||||
/** Create from a plugin's template: the plugin's appName and the template's directory name. */
|
||||
plugin?: string;
|
||||
template?: string;
|
||||
/** The owner's answers to that template's inputs. */
|
||||
config?: Record<string, string>;
|
||||
};
|
||||
const body = await ctx.req.json<Body>().catch(() => ({}) as Body);
|
||||
const name = body.name?.trim();
|
||||
@@ -553,15 +638,73 @@ commandHqRouter.post('/commanders', async (ctx) => {
|
||||
commander at a mailbox previously meant knowing its path and typing it, which meant a file browser
|
||||
and an account allowed to walk the disk. The button asks for neither.
|
||||
*/
|
||||
const referencePath = body.context ? chatContextDir(body.context, body.contextId, who) : body.referencePath?.trim();
|
||||
/*
|
||||
A template can supply the reference path from its own configuration.
|
||||
|
||||
`watch_dir` is what this kind of commander is about, so making the owner give a reference path AND a
|
||||
watch directory would be asking the same question twice and inviting the two to disagree.
|
||||
*/
|
||||
const template = body.plugin && body.template ? await getPluginCommander(body.plugin, body.template) : null;
|
||||
if (body.plugin && body.template && !template) {
|
||||
throw errors.BAD_REQUEST(`No commander template "${body.template}" in plugin "${body.plugin}"`);
|
||||
}
|
||||
|
||||
const fromConfig = template ? (body.config?.watch_dir ?? '').trim() : '';
|
||||
const referencePath = body.context
|
||||
? chatContextDir(body.context, body.contextId, who)
|
||||
: body.referencePath?.trim() || fromConfig;
|
||||
if (!referencePath) throw errors.BAD_REQUEST('referencePath or context is required');
|
||||
|
||||
const row = await createCommander({ userId: user.id, name, referencePath, categoryId: body.categoryId ?? null });
|
||||
const row = await createCommander({
|
||||
userId: user.id,
|
||||
name,
|
||||
referencePath,
|
||||
categoryId: body.categoryId ?? null,
|
||||
plugin: template?.plugin ?? null,
|
||||
pluginConfig: template ? (body.config ?? {}) : null,
|
||||
});
|
||||
const { dir, ownershipError, credentialError } = await createCommanderDirectory(who.email, row.dirName, {
|
||||
name,
|
||||
referencePath,
|
||||
userId: user.id,
|
||||
});
|
||||
/*
|
||||
A template's own files, written after the directory exists.
|
||||
|
||||
Three things, and each is deliberately a COPY rather than a reference into the plugin:
|
||||
|
||||
state/config.env what the owner answered, in the form the watcher sources
|
||||
watch.sh the loop, copied so `plugins:update` cannot change the script a commander is
|
||||
already running — a watcher whose code moves under it while armed is a thing
|
||||
nobody would be able to debug
|
||||
AGENTS.md the template's prose, appended to the brief already written
|
||||
|
||||
A failure here is reported and does not undo the commander. It exists, it is visible, and a missing
|
||||
config.env is exactly what the watcher refuses to start without — which is the configure-before-
|
||||
running rule enforcing itself rather than a half-created row nobody can see.
|
||||
*/
|
||||
let templateError: string | null = null;
|
||||
if (template) {
|
||||
try {
|
||||
const { writeFile, copyFile, appendFile } = await import('node:fs/promises');
|
||||
const stateDir = getCommanderStateDir(who.email, row.dirName);
|
||||
await writeFile(join(stateDir, 'config.env'), configEnv(template.inputs, body.config), { mode: 0o600 });
|
||||
|
||||
const script = join(template.dir, 'watch.sh');
|
||||
if (await Bun.file(script).exists()) {
|
||||
await copyFile(script, join(dir, 'watch.sh'));
|
||||
await (await import('node:fs/promises')).chmod(join(dir, 'watch.sh'), 0o755);
|
||||
}
|
||||
|
||||
if (template.body.trim()) {
|
||||
await appendFile(join(dir, 'AGENTS.md'), `\n\n---\n\n${template.body.trim()}\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
templateError = err instanceof Error ? err.message : 'could not write the template files';
|
||||
console.warn(`[command-hq] ${row.dirName}: template not fully written — ${templateError}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (credentialError) console.warn(`[command-hq] ${row.dirName}: no credential minted — ${credentialError}`);
|
||||
if (ownershipError) {
|
||||
console.warn(`[command-hq] ${row.dirName}: directory not handed to the account — ${ownershipError}`);
|
||||
@@ -581,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');
|
||||
@@ -611,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');
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { snapshotPlugins } from '../../plugins/mount';
|
||||
import { sidecarPort } from '../../sidecar-registry';
|
||||
|
||||
/**
|
||||
* File types an installed plugin can show and the platform cannot.
|
||||
*
|
||||
* ── One place, so three consumers cannot disagree ──
|
||||
*
|
||||
* `ls` decides whether an entry promises a thumbnail, `/thumb` decides where to get one, and the viewer
|
||||
* decides whether a full-size render exists. Those must be the same answer: a listing that promises a
|
||||
* thumbnail nothing will produce is a request that 404s on every scroll, and a viewer that offers a
|
||||
* render for a type nothing claims is a spinner that never resolves.
|
||||
*
|
||||
* ── Core does not know which plugin this is ──
|
||||
*
|
||||
* It reads `fileTypes` from whatever is installed AND ENABLED, and calls a path by name on that
|
||||
* plugin's own sidecar. Nothing here imports plugin code and nothing names av-club. Disable the plugin
|
||||
* and the claims vanish with it, which is what makes the file browser degrade to exactly what it was
|
||||
* rather than to something broken.
|
||||
*
|
||||
* ── Why the sidecar directly, and not our own HTTP surface ──
|
||||
*
|
||||
* A loopback request back through `/api` would need a credential to satisfy the platform's own auth,
|
||||
* and minting one so the server can call itself is a worse thing to have than this. `sidecarPort` is
|
||||
* the port the plugin announced when it started; the proxy in front of it exists for browsers, and the
|
||||
* server is already inside.
|
||||
*
|
||||
* ── `x-officer-user` is the whole authentication, and that is the sidecar's own design ──
|
||||
*
|
||||
* A plugin sidecar binds loopback and accepts that header from anything already on the machine — the
|
||||
* platform is the thing that decides who may call, and by the time this runs the account gate and the
|
||||
* permission gate have both passed. `sidecarFetch` below sends it because the proxy in front of the
|
||||
* sidecar sends it, and a caller that skips it gets a 401 that looks like the plugin being broken.
|
||||
*/
|
||||
|
||||
export type FileTypeClaim = {
|
||||
plugin: string;
|
||||
/** Absolute URL on the plugin's sidecar, or null when it did not offer that half. */
|
||||
thumbnail: string | null;
|
||||
render: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extension → who claims it. Lowercase keys, no leading dot.
|
||||
*
|
||||
* Not cached: `snapshotPlugins` reads the database, and this is called on a listing that is already
|
||||
* doing database work. A cache would need invalidating on install, uninstall, enable and disable —
|
||||
* four events to get wrong in exchange for a lookup that is already cheap.
|
||||
*/
|
||||
export async function pluginFileTypes(): Promise<Map<string, FileTypeClaim>> {
|
||||
const claims = new Map<string, FileTypeClaim>();
|
||||
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
try {
|
||||
({ states } = await snapshotPlugins());
|
||||
} catch {
|
||||
// The file browser still works. A database that cannot be read is a much louder problem elsewhere,
|
||||
// and losing every thumbnail to it would only obscure that.
|
||||
return claims;
|
||||
}
|
||||
|
||||
for (const state of states) {
|
||||
if (!state.install?.enabled) continue;
|
||||
|
||||
const declared = state.plugin.manifest?.fileTypes;
|
||||
if (!declared?.extensions?.length) continue;
|
||||
|
||||
const port = sidecarPort(state.plugin.appName);
|
||||
// Claimed but not running. Skipped rather than recorded, so `ls` does not promise a thumbnail that
|
||||
// cannot be fetched while the sidecar is restarting.
|
||||
if (!port) continue;
|
||||
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
for (const raw of declared.extensions) {
|
||||
const ext = String(raw).toLowerCase().replace(/^\./, '');
|
||||
// First claim wins. Two plugins claiming one extension is a conflict nobody has yet, and picking
|
||||
// arbitrarily-but-stably beats picking differently on each request.
|
||||
if (!ext || claims.has(ext)) continue;
|
||||
|
||||
claims.set(ext, {
|
||||
plugin: state.plugin.appName,
|
||||
thumbnail: declared.thumbnail ? `${base}${declared.thumbnail}` : null,
|
||||
render: declared.render ? `${base}${declared.render}` : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a plugin's sidecar the way its proxy would.
|
||||
*
|
||||
* The email is the account the request is being made FOR, not the server — a plugin scoping anything
|
||||
* per user reads this, and passing something else would be lying to it about who is asking.
|
||||
*/
|
||||
export async function sidecarFetch(url: string, email: string): Promise<Response> {
|
||||
return fetch(url, { headers: { 'x-officer-user': email } });
|
||||
}
|
||||
@@ -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';
|
||||
@@ -9,6 +10,9 @@ import * as errors from '@@/custom-errors';
|
||||
import { readTtsConfig } from '@@/api/server-settings/tts';
|
||||
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 {
|
||||
@@ -185,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.
|
||||
//
|
||||
@@ -203,18 +215,74 @@ router.get('/ls', async (ctx) => {
|
||||
}
|
||||
return ctx.json({ path: '/', entries: [], reset: true });
|
||||
}
|
||||
/*
|
||||
What a plugin can show that we cannot. Resolved ONCE per listing rather than per entry — it reads the
|
||||
database, and a folder of two hundred files would otherwise ask two hundred times for one answer.
|
||||
*/
|
||||
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;
|
||||
|
||||
/*
|
||||
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: s.isDirectory() ? 'directory' : 'file',
|
||||
size: s.size,
|
||||
modifiedAt: s.mtimeMs,
|
||||
type: isDirectory ? 'directory' : 'file',
|
||||
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.
|
||||
|
||||
The client had no way to know without its own copy, and the copies would have drifted — the
|
||||
browser's `NATIVE_VIDEO_EXTS` is deliberately only the formats a <video> tag can play, which
|
||||
is a different question from what ffmpeg can take a frame out of, and every .mkv in the
|
||||
folder would have gone without a picture for that reason alone.
|
||||
|
||||
Costs nothing: this entry has just been stat'd and the answer is a lookup on its name.
|
||||
*/
|
||||
/*
|
||||
Core's own answer first, then a plugin's claim.
|
||||
|
||||
A file only says `thumb` when something can actually produce one: core for the formats sharp
|
||||
and ffmpeg read, a plugin for what it declared in `fileTypes`. Without that second half, `.heic`
|
||||
promised a thumbnail nothing would make and cost a 422 on every scroll — which is why core
|
||||
stopped claiming it.
|
||||
*/
|
||||
thumb: isDirectory
|
||||
? null
|
||||
: (thumbnailKind(name) ??
|
||||
(claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase()) ? 'image' : null)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -301,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);
|
||||
@@ -308,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 });
|
||||
});
|
||||
|
||||
@@ -333,6 +413,119 @@ router.post('/rename', async (ctx) => {
|
||||
|
||||
// Serve raw file (binary-safe, for audio/video/images/download)
|
||||
// Supports Range requests for audio/video seeking
|
||||
/**
|
||||
* A small JPEG of an image or a video, for the browser's grid and list.
|
||||
*
|
||||
* ── Why not just point <img> at `/raw` ──
|
||||
*
|
||||
* Because a directory of photographs is then a directory of full-size downloads. Thirty 8MB images to
|
||||
* draw thirty 80px squares is a quarter of a gigabyte over the wire to show something the size of a
|
||||
* postage stamp, and it is worse on a phone, which is where it matters. Video cannot be done that way
|
||||
* at all — there is no frame until something decodes one.
|
||||
*
|
||||
* ── Authenticated by `?token=`, like `/raw` ──
|
||||
*
|
||||
* An `<img>` cannot send an Authorization header. `user-middleware` already accepts the token in the
|
||||
* query for exactly this reason, so this route needs no special handling — and it stays behind the same
|
||||
* gate as everything else here rather than becoming a public URL that happens to be hard to guess.
|
||||
*
|
||||
* ── `immutable`, honestly ──
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
router.get('/thumb', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
|
||||
if (!relPath) throw errors.BAD_REQUEST('path is required');
|
||||
|
||||
const absPath = resolveUserPath(rootDir, relPath);
|
||||
const size = clampSize(ctx.req.query('size'));
|
||||
const kind = thumbnailKind(relPath);
|
||||
|
||||
/*
|
||||
A type core cannot read, but an installed plugin can.
|
||||
|
||||
Forwarded to the plugin's own sidecar and cached HERE, under the same key everything else uses —
|
||||
so a HEIC thumbnail is produced once and then served from disk like any other, and the plugin is
|
||||
not asked again on every scroll.
|
||||
|
||||
`buildThumbnail` is given the fetch rather than a path: the caching, the concurrency limit and the
|
||||
remembered failures are all in that one function, and a second copy of them here for this one case
|
||||
is how the two would come to behave differently.
|
||||
*/
|
||||
if (!kind) {
|
||||
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
|
||||
if (claimed?.thumbnail) {
|
||||
const fetched = await buildThumbnail(absPath, getUserDataDir(user.email), 'image', size, async () => {
|
||||
const res = await sidecarFetch(
|
||||
`${claimed.thumbnail}?path=${encodeURIComponent(absPath)}&size=${size}`,
|
||||
user.email,
|
||||
);
|
||||
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
|
||||
});
|
||||
if (!fetched.ok) return ctx.text(fetched.reason, 422);
|
||||
return new Response(Bun.file(fetched.path), {
|
||||
headers: { 'Content-Type': 'image/jpeg', 'Cache-Control': 'private, max-age=31536000, immutable' },
|
||||
});
|
||||
}
|
||||
|
||||
// 415 rather than 404: the file is there, it is simply not something anything here can read. The
|
||||
// client does not ask for these, so this answers a stale tab rather than normal use.
|
||||
return ctx.text('no thumbnail for this file type', 415);
|
||||
}
|
||||
|
||||
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, size);
|
||||
// 422, and the client falls back to the file icon. Not 500: a file ffmpeg cannot read is a fact about
|
||||
// the file, and logging it as a server error would bury the ones that are.
|
||||
if (!result.ok) return ctx.text(result.reason, 422);
|
||||
|
||||
return new Response(Bun.file(result.path), {
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=31536000, immutable',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A displayable version of a file the browser cannot show itself.
|
||||
*
|
||||
* Only for types an installed plugin CLAIMS — everything a browser can already decode keeps going
|
||||
* through `/raw`, which streams bytes, supports ranges and lets the browser render progressively. This
|
||||
* route exists for exactly one situation: the file is an image, and no browser can display it.
|
||||
*
|
||||
* Not cached on disk like `/thumb`. A full-size render is large, viewed once, and keyed on a width the
|
||||
* viewer chooses — caching it would fill the disk to make the second look at one photograph faster. The
|
||||
* short `max-age` lets the browser hold it while somebody zooms.
|
||||
*/
|
||||
router.get('/render', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
|
||||
if (!relPath) throw errors.BAD_REQUEST('path is required');
|
||||
|
||||
const absPath = resolveUserPath(rootDir, relPath);
|
||||
const claimed = (await pluginFileTypes()).get(relPath.slice(relPath.lastIndexOf('.') + 1).toLowerCase());
|
||||
// 415 and not 404: the file is there. Nothing installed can show it, which is a different answer and
|
||||
// the one the viewer needs in order to say so rather than spin.
|
||||
if (!claimed?.render) return ctx.text('nothing installed can display this file type', 415);
|
||||
|
||||
const asked = Number(ctx.req.query('width'));
|
||||
const width = Number.isFinite(asked) && asked > 0 ? Math.min(Math.max(asked, 256), 4096) : 2048;
|
||||
|
||||
const res = await sidecarFetch(`${claimed.render}?path=${encodeURIComponent(absPath)}&width=${width}`, user.email);
|
||||
if (!res.ok) return ctx.text(`${claimed.plugin} could not read this file`, 422);
|
||||
|
||||
return new Response(await res.arrayBuffer(), {
|
||||
headers: {
|
||||
'Content-Type': res.headers.get('content-type') ?? 'image/webp',
|
||||
'Cache-Control': 'private, max-age=300',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/raw', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||
@@ -1090,6 +1283,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 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, stat } from 'node:fs/promises';
|
||||
import { extname, join } from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
|
||||
/**
|
||||
* Thumbnails for the file browser — one small JPEG per file, made once and cached.
|
||||
*
|
||||
* ── sharp for images, ffmpeg for video ──
|
||||
*
|
||||
* Each does the job it is actually for. sharp is libvips in-process: no process to spawn, no pipe, and
|
||||
* an order of magnitude less work for the common case, which is a folder of photographs. A video has no
|
||||
* frame until something decodes one, and that is ffmpeg's job and nobody else's.
|
||||
*
|
||||
* sharp was deliberately NOT used when this was first written, because it sat in `node_modules` only as
|
||||
* somebody else's transitive dependency and importing it would have been an undeclared dependency —
|
||||
* the thing the frozen lockfile exists to prevent. It is now a declared one, added through the
|
||||
* procedure `bunfig.toml` describes, so this note is history rather than a caveat.
|
||||
*
|
||||
* Both are infrastructure and both are checked by the setup script, because a machine missing either
|
||||
* fails at the point somebody opens a folder rather than at the point it was built.
|
||||
*
|
||||
* ── The cache key contains mtime and size, and that is what makes it safe ──
|
||||
*
|
||||
* A path alone would be wrong: replace a photo with a different one under the same name and every
|
||||
* screen keeps showing the old thumbnail, with nothing to clear it but a person who knows the cache
|
||||
* exists. With mtime and size in the hash, a changed file simply has a different key — the old entry
|
||||
* is orphaned rather than stale, and the response can be `immutable` because that URL's answer genuinely
|
||||
* never changes.
|
||||
*
|
||||
* ── Failures are remembered ──
|
||||
*
|
||||
* A corrupt file, a codec ffmpeg does not have, a zero-byte download: each would otherwise spawn a
|
||||
* process on every render, forever, for a file that will never produce a thumbnail. The browser retries
|
||||
* a broken <img> whenever it re-enters the viewport, so "no cache on failure" means a directory with one
|
||||
* bad video runs ffmpeg every time somebody scrolls past it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* What the client is allowed to ask about. Anything else is answered 415 rather than attempted.
|
||||
*
|
||||
* The image list grew when sharp arrived: libvips reads HEIC and SVG, which ffmpeg does not, and those
|
||||
* two are not exotic — HEIC is what every recent iPhone writes, and a folder of them showing generic
|
||||
* file icons was the most visible gap this had.
|
||||
*/
|
||||
/*
|
||||
No `heic` or `heif`, and that is not an oversight.
|
||||
|
||||
sharp's bundled libheif is built without the HEVC decoder, so it parses a HEIC's header and then
|
||||
fails on the pixels. Listing them here made `ls` promise a thumbnail core cannot produce: every HEIC
|
||||
cost a request that 422'd before falling back to the icon, and the promise was the only reason the
|
||||
request happened at all.
|
||||
|
||||
Decoding them needs ImageMagick and `libheif-plugin-libde265` — a system dependency the core has no
|
||||
business acquiring for one format. That belongs to a plugin, and av-club already does it.
|
||||
*/
|
||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg']);
|
||||
/*
|
||||
`ts` is NOT here, and that is the one entry worth explaining.
|
||||
|
||||
It is a real video extension — MPEG transport stream — and it was in this list until a deploy to fofos
|
||||
reported `test-setup.ts` as a video. On any machine Officer runs on, `.ts` is TypeScript approximately
|
||||
always, so including it meant every source file in every repository drew a play badge and spawned an
|
||||
ffmpeg that could only fail.
|
||||
|
||||
The two ways of being wrong are not the same size. Excluded, a genuine transport stream shows a file
|
||||
icon; included, browsing a source tree runs a decoder per file. Nobody stores broadcast captures in a
|
||||
folder they browse from here.
|
||||
*/
|
||||
const VIDEO_EXT = new Set([
|
||||
'mp4',
|
||||
'mov',
|
||||
'm4v',
|
||||
'webm',
|
||||
'mkv',
|
||||
'avi',
|
||||
'wmv',
|
||||
'flv',
|
||||
'mpg',
|
||||
'mpeg',
|
||||
'3gp',
|
||||
'm2ts',
|
||||
'mts',
|
||||
]);
|
||||
|
||||
export const thumbnailKind = (name: string): 'image' | 'video' | null => {
|
||||
const ext = extname(name).slice(1).toLowerCase();
|
||||
if (IMAGE_EXT.has(ext)) return 'image';
|
||||
if (VIDEO_EXT.has(ext)) return 'video';
|
||||
return null;
|
||||
};
|
||||
|
||||
/** Bigger than the grid tile draws, so a retina screen is not resampling upwards. */
|
||||
const MAX_SIZE = 512;
|
||||
const DEFAULT_SIZE = 256;
|
||||
|
||||
export const clampSize = (raw: string | undefined): number => {
|
||||
const asked = Number(raw);
|
||||
if (!Number.isFinite(asked)) return DEFAULT_SIZE;
|
||||
// Snapped to a step, so a panel resizing by a pixel cannot mint a hundred cache entries of one image.
|
||||
return Math.min(MAX_SIZE, Math.max(64, Math.round(asked / 64) * 64));
|
||||
};
|
||||
|
||||
/*
|
||||
How many thumbnail jobs may run at once.
|
||||
|
||||
The grid is virtualised, so only what is on screen is requested — but scrolling fast through a folder
|
||||
queues far more than is visible, and each item either spawns a decoder or turns libvips loose on a
|
||||
40-megapixel photograph. Four keeps a browse responsive on a small box; unbounded turned a 1.9GB
|
||||
machine into a swap storm once already, which is the whole reason there is a number here.
|
||||
|
||||
It covers BOTH paths deliberately, even though sharp is in-process and ffmpeg is not. The limit is
|
||||
about the machine, not about the mechanism: libvips uses its own thread pool per image, so a hundred
|
||||
concurrent resizes is no gentler than a hundred processes.
|
||||
*/
|
||||
const MAX_CONCURRENT = 4;
|
||||
let running = 0;
|
||||
const waiting: Array<() => void> = [];
|
||||
|
||||
const acquire = async (): Promise<void> => {
|
||||
if (running < MAX_CONCURRENT) {
|
||||
running += 1;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
running += 1;
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
running -= 1;
|
||||
waiting.shift()?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Paths that will never produce a thumbnail, by cache key — so a failure is not retried on every scroll.
|
||||
*
|
||||
* In memory rather than on disk: it is cheap to rebuild, and a restart is the natural moment to give a
|
||||
* 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;
|
||||
|
||||
const rememberFailure = (key: string) => {
|
||||
if (failed.size >= FAILED_LIMIT) failed.clear();
|
||||
failed.add(key);
|
||||
};
|
||||
|
||||
/** A frame from a second in, so a video that opens on black does not thumbnail as a black square. */
|
||||
const SEEK_SECONDS = '1';
|
||||
|
||||
/**
|
||||
* Run ffmpeg and report whether a FRAME CAME OUT — which is not the same as whether it succeeded.
|
||||
*
|
||||
* ffmpeg exits 0 having written a zero-byte file when the seek lands past the end of the input.
|
||||
* Observed on a 0.4s clip: `-ss 1` gave exit 0 and 0 bytes, and the same command without the seek gave
|
||||
* exit 0 and a real JPEG. So an exit code cannot decide whether to retry, and the earlier version of
|
||||
* this that trusted one reported every short video as having no thumbnail.
|
||||
*
|
||||
* The output file is the test, here and at the end. There is only one question worth asking of an
|
||||
* image encoder and it is whether there is an image.
|
||||
*/
|
||||
const spawnFfmpeg = async (args: string[], output: string): Promise<boolean> => {
|
||||
const proc = Bun.spawn(['ffmpeg', ...args], { stdout: 'ignore', stderr: 'ignore' });
|
||||
// 20s is generous for one frame and short enough that a wedged decode does not hold a slot forever.
|
||||
const timer = setTimeout(() => proc.kill(), 20_000);
|
||||
await proc.exited;
|
||||
clearTimeout(timer);
|
||||
|
||||
const written = await stat(output).catch(() => null);
|
||||
return written !== null && written.size > 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* One image, resized to fit the box.
|
||||
*
|
||||
* `fit: 'inside'` with `withoutEnlargement` matches what the ffmpeg path does — fit without cropping,
|
||||
* and never scale a 40px icon up to 256, which looks worse than leaving it small.
|
||||
*
|
||||
* `failOn: 'none'` is deliberate and is the difference between a browsable folder and a wall of icons.
|
||||
* libvips defaults to rejecting a file with any warning in it, and a truncated JPEG — the ordinary
|
||||
* result of an interrupted copy or a phone that ran out of battery mid-write — is exactly that. Every
|
||||
* viewer on earth will show what there is of such a file, so refusing to thumbnail it would be this
|
||||
* browser being stricter than the thing somebody opens the file with.
|
||||
*
|
||||
* `limitInputPixels: false` for the same reason applied to size: libvips refuses images past a default
|
||||
* pixel count as a denial-of-service guard, which is right for an upload endpoint and wrong here. These
|
||||
* are the owner's own files, already on the owner's own disk; a panorama is not an attack.
|
||||
*
|
||||
* Errors are swallowed to `false` rather than thrown — a file sharp cannot read is a fact about the
|
||||
* file, and the caller already turns that into a fallback icon and a remembered failure.
|
||||
*/
|
||||
const resizeImage = async (input: string, output: string, size: number): Promise<boolean> => {
|
||||
try {
|
||||
await sharp(input, { failOn: 'none', limitInputPixels: false, animated: false })
|
||||
.rotate() // Honour the EXIF orientation, or every photo taken sideways thumbnails sideways.
|
||||
.resize(size, size, { fit: 'inside', withoutEnlargement: true })
|
||||
.jpeg({ quality: 80, mozjpeg: true })
|
||||
.toFile(output);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export type ThumbnailResult = { ok: true; path: string } | { ok: false; reason: string };
|
||||
|
||||
/**
|
||||
* Produce (or find) the thumbnail for one file, and return where it is on disk.
|
||||
*
|
||||
* `userDataDir` is the caller's own data directory — the cache is per user, because the path being
|
||||
* thumbnailed is resolved against that user's root and two users' `/photos/a.jpg` are different files.
|
||||
*/
|
||||
export async function buildThumbnail(
|
||||
absPath: string,
|
||||
userDataDir: string,
|
||||
kind: 'image' | 'video',
|
||||
size: number,
|
||||
/**
|
||||
* Produce the bytes yourself instead of letting sharp or ffmpeg try.
|
||||
*
|
||||
* For a type core cannot read but an installed plugin can — see `plugin-file-types.ts`. It is passed
|
||||
* IN rather than handled by a second function so that everything around it is shared: the cache key
|
||||
* with its mtime and size, the concurrency limit, and the remembered failures. A parallel path for
|
||||
* plugin thumbnails would be a second cache to keep correct and a second place to forget the
|
||||
* negative caching, which is the part that stops one bad file spawning work on every scroll.
|
||||
*/
|
||||
produce?: () => Promise<Buffer | null>,
|
||||
): Promise<ThumbnailResult> {
|
||||
const info = await stat(absPath).catch(() => null);
|
||||
if (!info || !info.isFile()) return { ok: false, reason: 'not a file' };
|
||||
|
||||
const key = createHash('sha256').update(`${absPath}\0${info.mtimeMs}\0${info.size}\0${size}`).digest('hex');
|
||||
|
||||
if (failed.has(key)) return { ok: false, reason: 'no thumbnail for this file' };
|
||||
|
||||
/*
|
||||
Two levels of fan-out on the first byte of the hash. A single flat directory of thumbnails is fine
|
||||
until it is forty thousand entries, at which point `readdir` on it — which any backup or sync will
|
||||
do — becomes the slowest thing on the machine.
|
||||
*/
|
||||
const cacheDir = join(userDataDir, 'cache', 'thumbs', key.slice(0, 2), key.slice(2, 4));
|
||||
const cachePath = join(cacheDir, `${key}.jpg`);
|
||||
|
||||
const cached = await stat(cachePath).catch(() => null);
|
||||
if (cached?.isFile() && cached.size > 0) return { ok: true, path: cachePath };
|
||||
|
||||
// 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];
|
||||
|
||||
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) {
|
||||
/*
|
||||
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
|
||||
remembering that is what stops one bad video spawning work on every scroll.
|
||||
|
||||
A producer is a PLUGIN, in another process, reachable over a socket. It fails while it restarts,
|
||||
while it is busy behind its own concurrency limit, and for the few seconds after an update. Those
|
||||
all pass. Caching them meant a folder of HEIC came back as file icons and stayed that way until
|
||||
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' };
|
||||
}
|
||||
|
||||
return { ok: true, path: cachePath };
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight.set(key, work);
|
||||
try {
|
||||
return await work;
|
||||
} finally {
|
||||
inFlight.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@ 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';
|
||||
import { listPluginInstalls } from 'officerdb';
|
||||
import { iconUrl } from '../../app-store/assets';
|
||||
import {
|
||||
installPlugin,
|
||||
@@ -54,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".
|
||||
@@ -224,6 +229,103 @@ pluginsRouter.post('/update-all', async (ctx) => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* `https://host/owner/repo` or `…/repo.git` → `repo`.
|
||||
*
|
||||
* Derived rather than asked for, so the name and the address cannot disagree. `appName` IS the directory
|
||||
* a plugin sits in — never declared in a manifest — so this string becomes the route segment, the table
|
||||
* prefix and the sidecar suffix. Checked before anything is cloned: a half-clone under an unusable name
|
||||
* reads as a BROKEN plugin rather than an absent one, which is much harder to get out of.
|
||||
*/
|
||||
function pluginNameFromUrl(url: string, override?: string): { appName: string } | { error: string } {
|
||||
const derived = url
|
||||
.replace(/\.git$/, '')
|
||||
.replace(/\/+$/, '')
|
||||
.split('/')
|
||||
.pop();
|
||||
const appName = override?.trim() || derived;
|
||||
if (!appName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(appName)) {
|
||||
return {
|
||||
error:
|
||||
`"${appName ?? ''}" is not a usable plugin name. Lowercase letters, digits and single hyphens — ` +
|
||||
'it becomes the directory, the route and the table prefix.',
|
||||
};
|
||||
}
|
||||
return { appName };
|
||||
}
|
||||
|
||||
/*
|
||||
POST /plugins/fetch-from-url — put a plugin on the machine, and stop there.
|
||||
|
||||
The catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend"
|
||||
had no route. The first real case is one that must never be listed: fofos-admin is a single website's
|
||||
admin panel.
|
||||
|
||||
── Why this FETCHES and does not install ──
|
||||
|
||||
It was an install first, and that was wrong in a way only using it showed: the button lives on the
|
||||
grid, the progress log lives in the detail panel, so a URL install ran to completion with nothing on
|
||||
screen and the plugin simply appeared, installed. Streaming into a view that cannot render it is worse
|
||||
than not streaming.
|
||||
|
||||
Cloning alone is enough to make a plugin real — `/api/plugins` lists what is ON DISK with
|
||||
`installed: false`, so it shows up as Available like any catalogue entry. From there the existing card,
|
||||
the existing Install button and the existing streamed progress do the rest. One flow, not two.
|
||||
|
||||
── The name still comes from the URL ──
|
||||
|
||||
`appName` IS the directory, so the repository name decides the route segment, the table prefix and the
|
||||
process name. Checked before cloning: a half-clone under an unusable name reads as a BROKEN plugin
|
||||
rather than an absent one.
|
||||
|
||||
Owner-only like everything else here, and worth saying plainly — this puts somebody's code on the
|
||||
machine. It does not run it, which is exactly why installing stays a separate, deliberate click.
|
||||
*/
|
||||
pluginsRouter.post('/fetch-from-url', async (ctx) => {
|
||||
type FromUrl = { url?: string; appName?: string };
|
||||
const body = await ctx.req.json<FromUrl>().catch(() => ({}) as FromUrl);
|
||||
const url = body.url?.trim();
|
||||
if (!url) throw errors.BAD_REQUEST('url is required');
|
||||
|
||||
const named = pluginNameFromUrl(url, body.appName);
|
||||
if ('error' in named) throw errors.BAD_REQUEST(named.error);
|
||||
const { appName } = named;
|
||||
|
||||
const fetched = await fetchPlugin(appName, undefined, url);
|
||||
if (!fetched.ok) throw errors.BAD_REQUEST(fetched.error ?? `could not fetch ${appName}`);
|
||||
|
||||
/*
|
||||
Re-read from disk rather than trusting the clone.
|
||||
|
||||
`fetchPlugin` checks that `manifest.ts` EXISTS; discovery checks that it PARSES and says what a
|
||||
plugin needs to say. A directory that fails the second test still renders in the grid — as broken,
|
||||
which is the honest state — but saying so here means the person hears it while they are still
|
||||
looking at the field they pasted into.
|
||||
*/
|
||||
const { plugins, broken } = await discoverPlugins();
|
||||
const found = plugins.find((entry) => entry.appName === appName);
|
||||
const bad = broken.find((entry) => entry.appName === appName);
|
||||
if (!found) {
|
||||
return ctx.json(
|
||||
{ ok: false, appName, error: bad?.error ?? `cloned "${appName}", but it does not read as a plugin` },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
Whether it is ALREADY installed, so the UI can say the true thing and show the right tab.
|
||||
|
||||
A directory and an install row are separate facts — deleting a plugin's source leaves the row, so a
|
||||
re-fetch can land on something the platform still considers installed. Reported here because the
|
||||
caller has just been told "added" and needs to know where to look; "click it to install" pointing at
|
||||
an empty tab is worse than no message.
|
||||
*/
|
||||
const installs = await listPluginInstalls();
|
||||
const installed = installs.some((entry) => entry.appName === appName && entry.enabled);
|
||||
|
||||
return ctx.json({ ok: true, appName, label: found.manifest?.label ?? appName, installed });
|
||||
});
|
||||
|
||||
pluginsRouter.post('/:appName/install', async (ctx) => {
|
||||
const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id);
|
||||
return ctx.json(result, result.ok ? 200 : 400);
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createRouter } from '../../create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { TODO_STATUSES, bodyOf, frontmatter, patchFrontmatter, readTree, writeIndex } from './todo-tree';
|
||||
|
||||
/*
|
||||
/api/todos — the board over `todo/`, the platform's own backlog (one file per item, todo/README.md).
|
||||
|
||||
The FILESYSTEM is the database, deliberately: the items are already versioned, reviewed and synced
|
||||
between machines by git, and a second store would be a second truth. So this router is thin — read the
|
||||
tree, splice frontmatter — and everything it writes is a small, reviewable git diff.
|
||||
|
||||
Two rules from the format's contract, enforced here rather than trusted to callers:
|
||||
|
||||
- The BODY is the owner's half and is never rewritten. Only `patchFrontmatter` touches a file, and it
|
||||
stops at the closing fence.
|
||||
- `TODO.md` is generated output. Every write regenerates it via `writeIndex()`, so the index cannot
|
||||
drift from the tree — the board never edits it directly.
|
||||
|
||||
── Owner only, explicitly ──
|
||||
|
||||
`todos` is `kind: 'admin'` in the permission registry, so the global gate already refuses a non-owner.
|
||||
This is the same belt-and-braces the other admin routers add: a registry mistake must not turn into
|
||||
members reading — or editing — the platform's own backlog.
|
||||
*/
|
||||
|
||||
export const todosRouter = createRouter();
|
||||
|
||||
todosRouter.use(async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The todo board is owner-only');
|
||||
return next();
|
||||
});
|
||||
|
||||
/** `<theme>/<slug>`, both kebab-case — also the path traversal guard, since it admits no dot or slash pair. */
|
||||
const ID_SHAPE = /^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$/;
|
||||
|
||||
const fileForId = (id: string): string => {
|
||||
if (!ID_SHAPE.test(id)) throw errors.BAD_REQUEST(`not a todo id: ${id}`);
|
||||
const [theme, slug] = id.split('/');
|
||||
const path = resolve(process.cwd(), join('todo', theme!, `${slug}.md`));
|
||||
if (!existsSync(path)) throw errors.NOT_FOUND(`no todo at ${id}`);
|
||||
return path;
|
||||
};
|
||||
|
||||
/** GET /api/todos — the whole tree, no bodies. What the board renders. */
|
||||
todosRouter.get('/', (ctx) => ctx.json(readTree()));
|
||||
|
||||
/** GET /api/todos/item?id=<theme>/<slug> — one item, body included. Query param because the id holds a slash. */
|
||||
todosRouter.get('/item', (ctx) => {
|
||||
const text = readFileSync(fileForId(ctx.req.query('id') ?? ''), 'utf8');
|
||||
return ctx.json({ meta: frontmatter(text), body: bodyOf(text) });
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/todos/item — update an item's frontmatter: `{ id, status?, priority?, due?, order? }`.
|
||||
* Regenerates TODO.md on every write. The body is untouchable through this API, on purpose.
|
||||
*/
|
||||
todosRouter.patch('/item', async (ctx) => {
|
||||
const { id, status, priority, due, order } = await ctx.req.json<{
|
||||
id: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due?: string;
|
||||
order?: number;
|
||||
}>();
|
||||
const path = fileForId(id);
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
if (status !== undefined) {
|
||||
if (!(TODO_STATUSES as readonly string[]).includes(status)) throw errors.BAD_REQUEST(`not a status: ${status}`);
|
||||
updates.status = status;
|
||||
}
|
||||
// `urgent` or nothing — the format deliberately has no scale (todo/README.md).
|
||||
if (priority !== undefined) {
|
||||
if (priority !== '' && priority !== 'urgent') throw errors.BAD_REQUEST(`not a priority: ${priority}`);
|
||||
updates.priority = priority;
|
||||
}
|
||||
if (due !== undefined) {
|
||||
if (due !== '' && !/^20\d\d-\d\d-\d\d$/.test(due)) throw errors.BAD_REQUEST(`not a date: ${due}`);
|
||||
updates.due = due;
|
||||
}
|
||||
if (order !== undefined) {
|
||||
if (!Number.isInteger(order) || order < 0) throw errors.BAD_REQUEST(`not an order: ${order}`);
|
||||
updates.order = String(order);
|
||||
}
|
||||
if (Object.keys(updates).length === 0) throw errors.BAD_REQUEST('nothing to update');
|
||||
|
||||
writeFileSync(path, patchFrontmatter(readFileSync(path, 'utf8'), updates));
|
||||
writeIndex();
|
||||
return ctx.json({ ok: true });
|
||||
});
|
||||
@@ -10,8 +10,20 @@ import { join, resolve } from 'node:path';
|
||||
// the closing fence.
|
||||
// - `TODO.md` at the repo root is GENERATED output (`writeIndex`), never a write target.
|
||||
//
|
||||
// This module is the one reader and one writer. `scripts/todo-index.ts` (the CLI) and the `/api/todos`
|
||||
// router both import it, so the board and the generated index cannot disagree about what the tree holds.
|
||||
// ── This module has ONE caller now, and that is deliberate ──
|
||||
//
|
||||
// `scripts/todo-index.ts` — the CLI behind `bun todo:index`, which regenerates TODO.md from a terminal
|
||||
// with no Officer running. The `/api/todos` router and the board are gone: they became the `kanban`
|
||||
// plugin on 2026-08-29, which generalised this reader over a root so it can show any project's folder
|
||||
// rather than only the one the server was started in.
|
||||
//
|
||||
// This copy stayed because the folder it reads is the PLATFORM'S OWN backlog, committed with the
|
||||
// platform's own code. Moving it would have made regenerating this repository's TODO.md depend on a
|
||||
// plugin being installed, which is the wrong way round for a repo's own documentation.
|
||||
//
|
||||
// So there are two copies of this logic now, and they will drift. That is a known cost, recorded here
|
||||
// and in the plugin's README rather than discovered later. The honest fix, on the day it matters, is to
|
||||
// delete the CLI and let the plugin own the format outright.
|
||||
|
||||
/** Resolved per call, not at module load: the server asserts its cwd is the repo root at boot. */
|
||||
const todoDir = () => resolve(process.cwd(), 'todo');
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { getUserById } from 'officerdb';
|
||||
import { sign } from '@@/jwt';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { clientIp } from '../auth/client-ip';
|
||||
|
||||
/**
|
||||
* Sign in AS another account, from user management.
|
||||
*
|
||||
* The support tool this is for: "it does not work for them and it does for me". Reproducing that with a
|
||||
* password reset means locking the person out of their own account to look at it, which is worse than
|
||||
* the bug.
|
||||
*
|
||||
* ── The token says whose it is, and who is really holding it ──
|
||||
*
|
||||
* `impersonatedBy` is on the token and reaches every handler, because `user-middleware` puts the whole
|
||||
* payload on the context. Without it an impersonated action is indistinguishable from the person's own
|
||||
* — in a log, in a chat transcript, in anything keyed on the user id — and a support tool that can
|
||||
* forge activity as somebody else is a different and much worse thing.
|
||||
*
|
||||
* ── One hour, not thirty days ──
|
||||
*
|
||||
* A session token lasts a month because a person uses their own account daily. Impersonation is a task
|
||||
* you finish. An hour is long enough to reproduce a problem and short enough that a forgotten tab stops
|
||||
* being a live credential for somebody else's account by lunchtime.
|
||||
*
|
||||
* ── What it cannot do ──
|
||||
*
|
||||
* It cannot chain: `ownerGate` refuses a request whose token carries `impersonatedBy`, so an
|
||||
* impersonated session cannot impersonate a third account, and — the case that matters — impersonating
|
||||
* another Super Admin does not hand back user management. Otherwise this route would be a way to launder
|
||||
* owner actions through somebody else's id.
|
||||
*
|
||||
* It also cannot outlive the account: `user-middleware` re-reads the row on every request and refuses a
|
||||
* deleted or non-Active user, exactly as it does for an ordinary session.
|
||||
*
|
||||
* ── What it deliberately does NOT do ──
|
||||
*
|
||||
* It does not touch the owner's own token. Returning to yourself is the client putting back what it
|
||||
* kept, so there is no server state to leak, expire or get wrong — and closing the tab is not a way to
|
||||
* be stranded as somebody else.
|
||||
*/
|
||||
export const impersonateUserHandler: Handler = async (ctx) => {
|
||||
const actor = ctx.get('user') as { id: number; email?: string; impersonatedBy?: unknown };
|
||||
|
||||
// Belt to `ownerGate`'s braces: that gate already refuses an impersonated token, and this is the route
|
||||
// where being wrong about it is worst, so it is stated here too rather than assumed one file away.
|
||||
if (actor.impersonatedBy) throw errors.FORBIDDEN('An impersonated session cannot impersonate');
|
||||
|
||||
const targetId = Number(ctx.req.param('id'));
|
||||
if (!Number.isInteger(targetId) || targetId <= 0) throw errors.BAD_REQUEST('A user id is required');
|
||||
if (targetId === actor.id) throw errors.BAD_REQUEST('You are already yourself');
|
||||
|
||||
const target = await getUserById(targetId);
|
||||
if (!target) throw errors.NOT_FOUND('No such user');
|
||||
// The same refusal sign-in makes. Impersonating a blocked account would be a way to use it.
|
||||
if (target.status !== 'Active') throw errors.FORBIDDEN(`That account is ${target.status}`);
|
||||
|
||||
const { id, email, name, username } = target;
|
||||
const token = await sign({ id, email, name, username, impersonatedBy: { id: actor.id, email: actor.email } }, '1h');
|
||||
|
||||
// Same channel and shape as the distress revoke, so both land in one place when somebody asks who did
|
||||
// what. Names both parties: the id in every downstream record from here on will be the TARGET's.
|
||||
console.warn(
|
||||
`[SECURITY] impersonation started — actor=${actor.email ?? actor.id}, target=${email ?? id}, ` +
|
||||
`ip=${clientIp(ctx)} — ${new Date().toISOString()}`,
|
||||
);
|
||||
|
||||
return ctx.json({ token, user: { id, email, name, username }, expiresInMinutes: 60 });
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { createUserHandler } from './create-user';
|
||||
import { provisionLinuxHandler } from './provision-linux-route';
|
||||
import { resetUserPasswordHandler } from './reset-user-password';
|
||||
import { permissionAdminRouter } from './permissions-routes';
|
||||
import { impersonateUserHandler } from './impersonate';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
@@ -23,6 +24,17 @@ usersRouter.put('/', updateUserHandler);
|
||||
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
|
||||
const ownerGate: MiddlewareHandler = async (ctx, next) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
|
||||
/*
|
||||
An impersonated session is not the owner, whoever it is wearing.
|
||||
|
||||
`isSuperAdmin` reads the id on the token, so impersonating another Super Admin would otherwise hand
|
||||
user management straight back — and every action taken there would be recorded against the person
|
||||
impersonated rather than the person doing it. This one line is what stops impersonation being a way
|
||||
to launder owner actions through somebody else's account, and it is also what stops it chaining.
|
||||
*/
|
||||
if ((ctx.get('user') as { impersonatedBy?: unknown }).impersonatedBy) {
|
||||
throw errors.FORBIDDEN('An impersonated session cannot manage users');
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
@@ -36,6 +48,8 @@ usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
|
||||
// thing as auth/reset-password.ts, which is the token flow the person runs on themselves.
|
||||
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
|
||||
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
|
||||
// Sign in AS somebody, for an hour, on a token that says who is really holding it. See impersonate.ts.
|
||||
usersRouter.post('/:id/impersonate', ownerGate, impersonateUserHandler);
|
||||
|
||||
// Which permissions each role holds. Owner-gated inside its own router.
|
||||
usersRouter.route('/', permissionAdminRouter);
|
||||
|
||||
@@ -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}` };
|
||||
|
||||
@@ -43,7 +43,9 @@ export type CapabilitySource = {
|
||||
plugin: string | null;
|
||||
};
|
||||
|
||||
export async function capabilitySources(type: ItemType | 'affordances' | 'notifications'): Promise<CapabilitySource[]> {
|
||||
export async function capabilitySources(
|
||||
type: ItemType | 'affordances' | 'notifications' | 'commanders',
|
||||
): Promise<CapabilitySource[]> {
|
||||
const sources: CapabilitySource[] = [{ dir: itemsDir(type as ItemType), plugin: null }];
|
||||
|
||||
let states: Awaited<ReturnType<typeof snapshotPlugins>>['states'] = [];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import { themeFavoritesRouter } from './api/themes/favorites';
|
||||
import { waitlistRouter } from './api/waitlist/waitlist';
|
||||
import { usersRouter } from './api/users/users-router';
|
||||
import { rolesRouter } from './api/roles/router';
|
||||
import { todosRouter } from './api/todos/router';
|
||||
import { apiKeysRouter } from './api/api-keys/router';
|
||||
import { skillsRouter } from './api/skills/skills';
|
||||
import { tasksRouter } from './api/tasks/tasks';
|
||||
@@ -131,7 +130,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
|
||||
['/theme-favorites', themeFavoritesRouter],
|
||||
['/users', usersRouter],
|
||||
['/roles', rolesRouter],
|
||||
['/todos', todosRouter],
|
||||
['/skills', skillsRouter],
|
||||
['/tasks', tasksRouter],
|
||||
['/agents', agentsRouter],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -435,17 +435,6 @@ const CORE_REGISTRY: Permission[] = [
|
||||
// is a real route — a client-side redirect still has to resolve before it can send anyone anywhere.
|
||||
routes: ['/marketplace', '/plugins'],
|
||||
},
|
||||
{
|
||||
key: 'todos',
|
||||
label: 'Todo board',
|
||||
description: "The platform's own backlog — todo/ in the repo, browsed and edited at /todos",
|
||||
// Admin because the subject is the platform's development, not the account's data: the tree lives in
|
||||
// the server's own checkout and a write is a git-visible edit to it. The router double-gates on
|
||||
// isSuperAdmin, same belt-and-braces as the rest of this kind.
|
||||
kind: 'admin',
|
||||
api: ['/todos'],
|
||||
routes: ['/todos'],
|
||||
},
|
||||
{
|
||||
key: 'server-admin',
|
||||
label: 'Server settings',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -40,7 +40,7 @@ const gitAvailable = (): boolean => !!Bun.which('git');
|
||||
* verb with different questions — what happens to uncommitted changes, does the version go backwards —
|
||||
* and it does not exist yet.
|
||||
*/
|
||||
export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<FetchResult> {
|
||||
export async function fetchPlugin(appName: string, onLog?: OnLog, source?: string): Promise<FetchResult> {
|
||||
const dir = join(PLUGINS_DIR, appName);
|
||||
|
||||
if (existsSync(dir)) {
|
||||
@@ -50,8 +50,20 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
|
||||
return { ok: false, dir, error: 'git is not installed on this machine' };
|
||||
}
|
||||
|
||||
const entry = await catalogueEntry(appName);
|
||||
if (!entry) return { ok: false, dir, error: `no catalogue entry for "${appName}"` };
|
||||
/*
|
||||
A URL the caller supplied, or the catalogue's.
|
||||
|
||||
The catalogue is not the only way a plugin should be able to arrive. "Build one and give it to a
|
||||
friend" had no route at all, and the first real case is a plugin that must NOT be listed: fofos-admin
|
||||
is one website's admin panel and belongs in nobody's registry.
|
||||
|
||||
The URL is checked identically either way, below. It has to be — the catalogue is not a trust
|
||||
boundary here, it is just where a string came from, and the same string reaches the same command
|
||||
line. What DOES differ is who is asking: an install from a URL is somebody typing an address, so the
|
||||
route that accepts one is owner-only.
|
||||
*/
|
||||
const url = source?.trim() || (await catalogueEntry(appName))?.source;
|
||||
if (!url) return { ok: false, dir, error: `no catalogue entry for "${appName}", and no source URL given` };
|
||||
|
||||
// The URL comes from the catalogue and reaches a command line, so it is checked rather than trusted.
|
||||
// Deliberately narrow: https only, no credentials in the URL, no shell metacharacters. A private
|
||||
@@ -63,8 +75,8 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
|
||||
// refused a completely valid, credential-free, https clone URL with a message ("https URLs only, no
|
||||
// credentials") that describes neither actual reason. Row fixed for consistency with its siblings, but
|
||||
// the regex should never have depended on it.
|
||||
if (!/^https:\/\/[A-Za-z0-9._~\-/]+(\.git)?$/.test(entry.source)) {
|
||||
return { ok: false, dir, error: `refusing to clone from "${entry.source}" — https URLs only, no credentials` };
|
||||
if (!/^https:\/\/[A-Za-z0-9._~\-/]+(\.git)?$/.test(url)) {
|
||||
return { ok: false, dir, error: `refusing to clone from "${url}" — https URLs only, no credentials` };
|
||||
}
|
||||
|
||||
const result = await runLogged(
|
||||
@@ -74,7 +86,7 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise<Fetch
|
||||
// `--progress` instead of `--quiet`: git only reports progress when it believes it has a terminal, and
|
||||
// this is the step where a slow link looks identical to a hang. `runLogged` collapses the per-percent
|
||||
// rewrites, so the counter is readable rather than a flood.
|
||||
['git', 'clone', '--depth', '1', '--progress', entry.source, dir],
|
||||
['git', 'clone', '--depth', '1', '--progress', url, dir],
|
||||
{
|
||||
// No terminal to answer a credential prompt: without this, a repository that turned private hangs
|
||||
// the install forever rather than failing it. Same reasoning as `sudo -n` in os-deps.ts.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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';
|
||||
@@ -145,6 +145,14 @@ export async function installPlugin(
|
||||
onStep?: OnStep,
|
||||
forUserId?: number,
|
||||
onLog?: OnLog,
|
||||
/**
|
||||
* Clone from HERE instead of from the catalogue.
|
||||
*
|
||||
* For a plugin that is not in any registry and should not be — one website's admin panel, something
|
||||
* built for one person. Ignored when the plugin is already on disk, because then there is nothing to
|
||||
* fetch and a URL would be describing a decision already made.
|
||||
*/
|
||||
source?: string,
|
||||
): Promise<PluginActionResult> {
|
||||
const steps: string[] = [];
|
||||
|
||||
@@ -160,7 +168,7 @@ export async function installPlugin(
|
||||
// as it was.
|
||||
let plugin = await findPlugin(appName);
|
||||
if (!plugin) {
|
||||
const fetched = await fetchPlugin(appName, onLog);
|
||||
const fetched = await fetchPlugin(appName, onLog, source);
|
||||
if (!fetched.ok) {
|
||||
return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` };
|
||||
}
|
||||
@@ -445,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);
|
||||
@@ -601,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.
|
||||
*
|
||||
@@ -477,6 +522,77 @@ export type PluginManifest = {
|
||||
settings?: PluginSetting[];
|
||||
|
||||
websocket?: { sidecar: string };
|
||||
/**
|
||||
* File types this plugin can show that the platform cannot.
|
||||
*
|
||||
* ── Why this exists ──
|
||||
*
|
||||
* The file browser's thumbnailer reads what sharp and ffmpeg read, and that list is a constant in
|
||||
* core. HEIC is the case that forced the question: sharp's bundled libheif has no HEVC decoder, and
|
||||
* teaching core to shell out to ImageMagick would mean the platform acquiring a system dependency for
|
||||
* one format — which is a plugin's job, not core's.
|
||||
*
|
||||
* So a plugin may CLAIM extensions. `ls` unions these with core's own, so a file only promises a
|
||||
* thumbnail when something can actually produce one; `/thumb` forwards a claimed type to the plugin
|
||||
* that claimed it; and the viewer asks for `render` when opening one full size.
|
||||
*
|
||||
* ── The dependency still points the right way ──
|
||||
*
|
||||
* Core does not import plugin code and does not know av-club exists. It reads a claim at runtime from
|
||||
* whatever is installed and enabled, calls a route by name, and falls back to a file icon when nothing
|
||||
* claims the type. Uninstall the plugin and the file browser is exactly what it was.
|
||||
*
|
||||
* `thumbnail` and `render` are paths on the plugin's own sidecar, called with `?path=<absolute>` —
|
||||
* plus `&size=` for a thumbnail and `&width=` for a render. Both must answer image bytes.
|
||||
*/
|
||||
fileTypes?: {
|
||||
/** Lowercase, no leading dot. `['heic', 'heif']`. */
|
||||
extensions: string[];
|
||||
/** Sidecar path returning a small image. Omit and the platform will not offer thumbnails for these. */
|
||||
thumbnail?: string;
|
||||
/** Sidecar path returning a full-size displayable image. Omit and the viewer will not offer them. */
|
||||
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[];
|
||||
|
||||
/**
|
||||
@@ -565,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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -873,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;
|
||||
@@ -502,6 +511,32 @@ const BUNDLED: CatalogueEntry[] = [
|
||||
],
|
||||
has: { api: true, schema: true, sidecar: true, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'kanban',
|
||||
publisher: 'officerdev',
|
||||
version: '2026.08.29.01',
|
||||
platform: '>=1.0.0',
|
||||
label: 'Kanban',
|
||||
summary: 'A board over folders of markdown todo files — as many projects as you point it at',
|
||||
color: '#8b5cf6',
|
||||
icon: 'SquareKanban',
|
||||
source: 'https://gitea.officer.dev/plugins/kanban.git',
|
||||
permissions: [
|
||||
{
|
||||
key: 'kanban',
|
||||
label: 'Kanban',
|
||||
description: 'Read and edit todo boards over folders you have added',
|
||||
},
|
||||
],
|
||||
/*
|
||||
`schema: true` for one table, and it is worth saying what is in it: a list of FOLDERS. the items
|
||||
are markdown on disk and this plugin keeps no copy of them, so an uninstall with "delete its data"
|
||||
ticked loses the list and never a line of anybody's work.
|
||||
|
||||
`sidecar: false` — it reads and writes files with node's own fs. there is nothing to run.
|
||||
*/
|
||||
has: { api: true, schema: true, sidecar: false, web: true },
|
||||
},
|
||||
{
|
||||
appName: 'example',
|
||||
publisher: 'officerdev',
|
||||
@@ -530,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.
|
||||
@@ -613,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})`);
|
||||
|
||||
@@ -206,6 +206,45 @@ const handleRevokedSession = (url: string) => {
|
||||
if (revocationHandled) return;
|
||||
revocationHandled = true;
|
||||
|
||||
/*
|
||||
Guard 2b: CONFIRM the session is dead before destroying it.
|
||||
|
||||
A 401 from any one endpoint used to be enough to wipe the token out of both storages and reload —
|
||||
which signs the owner out of every tab, and keeps them signed out, because there is then no token
|
||||
anywhere to recover from. That is the right response to a revoked session and far too violent a
|
||||
response to one endpoint answering 401 for its own reasons.
|
||||
|
||||
Reported 2026-08-27, mid-demo: every refresh and every new tab came back signed out, with nothing in
|
||||
the logs and a freshly signed token working fine against every route the app loads. One transient
|
||||
401 is indistinguishable from a revocation at the call site — so ask the one endpoint whose whole
|
||||
job is to answer it.
|
||||
|
||||
`/auth/me` is exempt from this handler (the `/auth/` guard above), so this cannot recurse.
|
||||
*/
|
||||
void (async () => {
|
||||
try {
|
||||
// Derived from the URL that just failed, because the base is passed per call rather than held in
|
||||
// config — a relative guess would be wrong on any host that mounts the API elsewhere.
|
||||
const probe = /\/api\//.test(url) ? url.replace(/\/api\/.*$/, '/api/auth/me') : '/api/auth/me';
|
||||
const res = await fetch(probe, { headers: { Authorization: `Bearer ${theToken}` } });
|
||||
// Still good: that 401 belonged to one endpoint, not to the session. Leave everything alone and
|
||||
// let the next revocation try again.
|
||||
if (res.ok) {
|
||||
revocationHandled = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The server is unreachable — a restart, a dropped network. Emphatically not a revocation, and
|
||||
// wiping the session because the box was rebooting is the failure this guard exists to stop.
|
||||
revocationHandled = false;
|
||||
return;
|
||||
}
|
||||
destroySession();
|
||||
})();
|
||||
};
|
||||
|
||||
/** Actually end it: forget the token everywhere a load could read it back, then reload once. */
|
||||
const destroySession = () => {
|
||||
theToken = null;
|
||||
// Every key `createClient` reads, or the next load signs straight back in with the dead token.
|
||||
for (const key of ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN']) {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as sendOffMetas } from '../apps/SendOff';
|
||||
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
|
||||
import { appRegistryMetas as todoBoardMetas } from '../apps/TodoBoard';
|
||||
import type { QueryClient } from '@tanstack/react-query';
|
||||
import { globalQueryKey } from 'hooks/useGlobal';
|
||||
import { metasToRegistry } from './useAppRegistry';
|
||||
@@ -31,7 +30,6 @@ export const apps = [
|
||||
...qrTransferMetas,
|
||||
...sendOffMetas,
|
||||
...commandHqMetas,
|
||||
...todoBoardMetas,
|
||||
...appStoreMetas,
|
||||
...pluginsMetas,
|
||||
];
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Plus } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath } from './command-hq-routes';
|
||||
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||
import { useCommandHqCategories } from './useCommandHq';
|
||||
import type { CommandHqCategoryFilter } from './types';
|
||||
import { useTranslation } from '../../locales';
|
||||
@@ -36,6 +37,11 @@ export const CommandHqCategories = () => {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const active: CommandHqCategoryFilter = categoryFromParam(searchParams.get('category'));
|
||||
|
||||
// Which plugins have anything to offer. Derived from the templates rather than from the installed
|
||||
// plugin list: a plugin that ships no commander should not get a row that is always empty.
|
||||
const { templates } = usePluginCommanderTemplates();
|
||||
const plugins = [...new Set(templates.map((template) => template.plugin))].sort();
|
||||
const { categories, createCategory } = useCommandHqCategories();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
@@ -96,6 +102,34 @@ export const CommandHqCategories = () => {
|
||||
to={commandHqListPath(category.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/*
|
||||
One row per plugin that offers commanders, below the owner's own categories and separated from
|
||||
them.
|
||||
|
||||
Separated because they are not the same kind of thing. A category is the owner's filing and they
|
||||
can rename or delete it; a plugin is where something came from and they cannot. Mixing them into
|
||||
one list would invite the reasonable assumption that "av-club" is a folder they made, and the
|
||||
first thing somebody does with a folder they made is rename it.
|
||||
|
||||
Shown even when the plugin has no commanders yet — that is the whole point. The row is how you
|
||||
find out a plugin is offering one.
|
||||
*/}
|
||||
{plugins.length > 0 && (
|
||||
<div className="mt-2 border-t border-border pt-2">
|
||||
<span className="block px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
|
||||
{t('commandHq.fromPlugins')}
|
||||
</span>
|
||||
{plugins.map((plugin) => (
|
||||
<Row
|
||||
key={plugin}
|
||||
label={plugin}
|
||||
active={active === `plugin:${plugin}`}
|
||||
to={commandHqListPath(`plugin:${plugin}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{adding && (
|
||||
// No submit button — a form with exactly one text field still submits on Enter.
|
||||
<form onSubmit={submitNew}>
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
useCommandHqCommanderActions,
|
||||
useCreateCommander,
|
||||
} from './useCommandHq';
|
||||
import type { CommandHqCommanderCreated } from './types';
|
||||
import type { CommandHqCommanderCreated, CommandHqCategoryFilter } from './types';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import { NEW_COMMANDER_GUIDE } from './newCommanderGuide';
|
||||
import { useTranslation } from '../../locales';
|
||||
@@ -246,7 +246,7 @@ function NewCommanderSetup({
|
||||
category,
|
||||
onCreated,
|
||||
}: {
|
||||
category: number | 'none' | undefined;
|
||||
category: CommandHqCategoryFilter;
|
||||
onCreated: (commander: CommandHqCommanderCreated) => void;
|
||||
}) {
|
||||
/*
|
||||
@@ -335,7 +335,7 @@ function ActiveNewCommander({
|
||||
onStarted,
|
||||
}: {
|
||||
commander: CommandHqCommanderCreated;
|
||||
category: number | 'none' | undefined;
|
||||
category: CommandHqCategoryFilter;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
const { t, tUi, locale } = useTranslation();
|
||||
@@ -446,13 +446,7 @@ function ActiveNewCommander({
|
||||
* version had (a `messages.length === 0` latch that could not tell "never started" from "started, and the
|
||||
* socket dropped").
|
||||
*/
|
||||
function NewCommander({
|
||||
category,
|
||||
onStarted,
|
||||
}: {
|
||||
category: number | 'none' | undefined;
|
||||
onStarted: (id: string) => void;
|
||||
}) {
|
||||
function NewCommander({ category, onStarted }: { category: CommandHqCategoryFilter; onStarted: (id: string) => void }) {
|
||||
const [commander, setCommander] = useState<CommandHqCommanderCreated | null>(null);
|
||||
if (!commander) return <NewCommanderSetup category={category} onCreated={setCommander} />;
|
||||
return <ActiveNewCommander commander={commander} category={category} onStarted={onStarted} />;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Check, Plus, Trash2, X } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/sonner';
|
||||
import { errorText } from 'helpers/error-text';
|
||||
import { categoryFromParam, commandHqListPath, commandHqNewPath, commandHqSessionPath } from './command-hq-routes';
|
||||
import { PluginCommanderOffers } from './PluginCommanderOffers';
|
||||
import { useCommandHqCommanderActions, useCommandHqCommanders } from './useCommandHq';
|
||||
import { formatCommanderCost, formatTokens } from './format';
|
||||
import type { CommandHqCategoryFilter, CommandHqCommander } from './types';
|
||||
@@ -170,6 +171,12 @@ export const CommandHqGrid = () => {
|
||||
row.
|
||||
*/}
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{/* Only on a plugin's own filter. On "All commanders" this would put every plugin's offers above
|
||||
a list that is mostly not theirs, which is a different screen pretending to be this one. */}
|
||||
{typeof category === 'string' && category.startsWith('plugin:') && (
|
||||
<PluginCommanderOffers plugin={category.slice('plugin:'.length)} />
|
||||
)}
|
||||
|
||||
{commanders.map((commander) => (
|
||||
<Card
|
||||
key={commander.id}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user