Compare commits

..
Author SHA1 Message Date
pastilhasandClaude Opus 5 9cb875ca79 core stops claiming HEIC it cannot decode
the revert above took the ImageMagick path back out of core, where it should never
have gone: decoding HEIC needs ImageMagick and libheif-plugin-libde265, and the
core has no business acquiring a system dependency for one format. that is a
plugin's job.

this removes the other half of the same mistake. `heic` and `heif` were in core's
extension list from the day the thumbnailer was written, which made `ls` promise a
thumbnail sharp cannot produce — so every HEIC in a folder cost a request that
422'd before falling back to the icon, and the promise was the only reason the
request happened.

so without a plugin the file browser is now what it was: a file icon for a HEIC,
and no wasted round trip earning it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:41:17 +01:00
pastilhas c5b95becd2 Revert "HEIC gets a thumbnail, through the decoder that can actually read it"
This reverts commit f4b9596a88.
2026-08-29 07:39:52 +01:00
pastilhasandClaude Opus 5 f4b9596a88 HEIC gets a thumbnail, through the decoder that can actually read it
sharp ships its own libvips and libheif built WITHOUT the HEVC decoder, so a HEIC
parses its header, reports 4032x3024, and then fails on the pixels with "bad seek".
that reads like a corrupt file and is not one — every photograph an iPhone takes is
HEVC-coded HEIF, so it is most of what arrives from a phone.

ImageMagick links the SYSTEM libheif, which has the codec. so a `.heic` goes
through `convert` to a lossless PNG and sharp takes it from there. PNG rather than
JPEG for the intermediate so the single real encode is the final one and the
thumbnail is not a copy of a copy. removed in a `finally` either way — a failed
decode must not leave a stray file a later run mistakes for a finished thumbnail.

this is deliberately the same approach av-club has used for weeks
(sidecar/sharp/decodable.ts), on these exact files. copied rather than shared,
because the platform must not import from a plugin — the dependency only runs the
other way, and fifteen lines of spawn is cheaper than inverting it.

`convert` exits 0 having written nothing when the codec is missing, so the OUTPUT
FILE is the test rather than the exit code. that is not defensive: it is precisely
the failure this exists to handle.

IT NEEDS `libheif-plugin-libde265`, which is not in a default Ubuntu install —
libheif packages its codecs separately and ships the AV1 one, not the HEVC one.
declared in av-club's manifest today, and installed on this host to prove the path.
without it, `convert` fails the same way sharp does and the browser falls back to
the file icon, which is the correct degradation rather than a break.

the in-memory failure cache did exactly what its comment said it would: the HEIC
stayed 422 after the codec was installed, until a restart. "a restart is the
natural moment to give a file another chance after somebody has installed a codec"
— written weeks ago, met today.

verified: a real iPhone HEIC, 2 MB in, 12 KB out, 5.3s cold and 22ms cached, and
the image is right. png, svg and mp4 unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:36:32 +01:00
pastilhasandClaude Opus 5 09518e1e1f the three files fb1cf0f6 was supposed to carry
fb1cf0f6 deleted the todo router, the board and the screen but did NOT remove the
imports of them. so a checkout of that commit has hono.ts importing
`./api/todos/router`, which is not there — and the server cannot start.

fofos hit it within a minute of pulling. it was down until this commit.

WHY IT PASSED EVERY CHECK HERE: the files were modified in my working tree and
never staged, so this machine typechecked, tested and ran against the fix while the
commit shipped without it. every green light I reported was true of the tree in
front of me and false of the thing I pushed.

HOW IT HAPPENED, precisely, because the shape is worth knowing: the `git add` naming
these three also named a path that no longer existed. `git add` ABORTS ON AN
UNMATCHED PATHSPEC — it does not stage the rest and warn. It staged nothing. the
`git status` I printed straight afterwards showed them as ` M`, unstaged, and I read
the output as confirmation rather than as the warning it was.

the lesson is not "look harder at git status". it is that a commit which deletes a
module and a commit which stops importing it must be the same commit, and the only
thing that proves it is a check against the COMMIT rather than the working tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:19:00 +01:00
pastilhasandClaude Opus 5 500bb925eb the directory picker becomes shared, because two plugins wanted it
it was written inside fofos-admin, and then kanban's project form asked for a path
and got a TEXT FIELD — because a plugin cannot import from a sibling plugin and I
took the cheap way out rather than moving it. that is the wrong trade for a
component whose entire job is not letting somebody type a path wrong.

nothing in it was fofos-specific: it browses the owner's home over the platform's
own /file-browser/ls and /mkdir. so it moves to the officerdev barrel that plugins
already import from, and both use the one.

it takes the platform's `errorText` rather than fofos-admin's `readableError`,
which knew about that plugin's three particular failures — unconfigured,
unreachable, refused. here there are two: cannot read that folder, or not allowed.

copying it instead would have left two of them to drift, and the version that
drifts is always the one somebody is looking at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:09:37 +01:00
pastilhasandClaude Opus 5 a970cb7c5f kanban joins the catalogue
it was on disk and discovered but not in BUNDLED, so the plugins page classified it
as "a developer working on one" rather than something anybody could install. that
is the file's own three-state vocabulary working correctly on an entry I forgot to
write — building the plugin and creating its repository does not put it in a
catalogue that is still a hand-maintained constant.

the entry repeats the manifest because a remote plugin has no directory to read:
everything shown BEFORE installing has to be declared here, including the two things
worth seeing before consenting — the permissions it wants and the binaries it would
make the machine acquire. kanban wants one permission and no binaries.

`schema: true` for one table, and the entry says what is in it: a list of FOLDERS.
the items are markdown on disk and this plugin keeps no copy, so an uninstall with
"delete its data" ticked loses the list and never a line of anybody's work. that is
the thing somebody should be able to read before they install it.

`sidecar: false` — it reads and writes files with node's own fs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 07:03:02 +01:00
pastilhasandClaude Opus 5 fb1cf0f6f1 the todo board leaves the platform
`/todos` is now the `kanban` plugin. removed here: the router, the screen, the
route, the TodoBoard panel, its AppRegistry entry and the `todos` permission.

WHAT STAYED, and it is a seam rather than an oversight: `todo/` and
`scripts/todo-index.ts`. that folder is the platform's OWN backlog, committed with
the platform's own code, and the CLI regenerates TODO.md from a terminal with no
Officer running. moving either would have made this repository's own documentation
depend on a plugin being installed.

so `todo-tree.ts` stays too, with one caller instead of two, and there are now two
copies of that logic which will drift. recorded in the file itself and in the
plugin's README rather than discovered in six months. the honest fix on the day it
matters is to delete the CLI and let the plugin own the format.

THE TOTALITY CHECK EARNED ITS KEEP. removing the router left `todos` in
permissions/registry.ts and the server REFUSED TO BOOT — exactly what that guard is
for. a stale permission naming a route that no longer exists is the kind of thing
that otherwise sits there for months granting access to nothing.

docs:check then caught two more: the generated platform skill still listed `/todos`,
and AGENTS.md claimed 21 permission entries against a registry now holding 20. both
fixed, and the AGENTS.md note now explains the drop the same way it already explains
caldav's.

verified: server boots, `bun todo:index` still regenerates TODO.md, 1665 tests pass
(up from 1657 — the ported tree tests run in the plugin now), typecheck clean,
docs:check green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-29 03:29:58 +01:00
pastilhas a947df8d9d deploy report for 2026.08.29.02 — 7 of 7, plugin columns everywhere 2026-08-29 03:19:40 +01:00
19 changed files with 330 additions and 412 deletions
+1 -1
View File
@@ -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`.
+74
View File
@@ -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.
-1
View File
@@ -107,7 +107,6 @@ export function App() {
{/* 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,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';
+12 -1
View File
@@ -43,7 +43,18 @@ import sharp from 'sharp';
* 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.
*/
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg', 'heic', 'heif']);
/*
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.
-93
View File
@@ -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 });
});
+14 -2
View File
@@ -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');
-2
View File
@@ -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],
-11
View File
@@ -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',
+26
View File
@@ -502,6 +502,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',
@@ -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,
];
@@ -1,215 +0,0 @@
import type { DragEvent } from 'react';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { AlertTriangle, CalendarClock, X } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { TodoItem } from './useTodos';
import { useTodoItem, useTodoPatch, useTodoTree } from './useTodos';
/**
* The board over `todo/` — the platform's own backlog, one file per item (todo/README.md).
*
* Selection and the theme filter live in the URL (`?selected=`, `?theme=`), per the navigation audit:
* a card is a real link, a filtered board is an addressable page, and back means what it should.
*
* Writes go through PATCH /todos/item, which touches frontmatter only and regenerates TODO.md — this
* board never holds state the files do not; a drag is a file edit and a refetch.
*/
const COLUMNS = [
{ status: 'open', label: 'Open' },
{ status: 'doing', label: 'Doing' },
{ status: 'blocked', label: 'Blocked' },
{ status: 'backburner', label: 'Backburner' },
{ status: 'done', label: 'Done' },
] as const;
export const TodoBoardPanel = () => {
const { data, isLoading, error } = useTodoTree();
const patch = useTodoPatch();
const [params] = useSearchParams();
const selected = params.get('selected');
const themeFilter = params.get('theme');
const [dragOver, setDragOver] = useState<string | null>(null);
const href = (changes: Record<string, string | null>): string => {
const next = new URLSearchParams(params);
for (const [key, value] of Object.entries(changes)) {
if (value === null) next.delete(key);
else next.set(key, value);
}
const qs = next.toString();
return qs ? `?${qs}` : '.';
};
if (error) {
return (
<div className="flex h-full items-center justify-center p-8 text-center text-sm text-muted-foreground">
The todo board is owner-only, and this account was refused. Nothing here is broken — there is simply nothing to
show.
</div>
);
}
if (isLoading || !data) {
return <div className="flex h-full items-center justify-center text-sm text-muted-foreground">Reading todo/…</div>;
}
const themes = data.themes.filter((t) => t.items.length > 0);
const items = themes.flatMap((t) => t.items);
const visible = themeFilter ? items.filter((i) => i.theme === themeFilter) : items;
const drop = (status: string) => (ev: DragEvent) => {
ev.preventDefault();
setDragOver(null);
const id = ev.dataTransfer.getData('text/todo-id');
if (id) patch.mutate({ id, status });
};
return (
<div className="flex h-full min-h-0">
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex flex-wrap items-center gap-1.5 border-b border-border px-4 py-2">
<Link
to={href({ theme: null })}
className={cn(
'rounded-full px-2.5 py-0.5 text-xs',
!themeFilter
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground',
)}
>
All · {items.filter((i) => i.status !== 'done').length}
</Link>
{themes.map((t) => {
const open = t.items.filter((i) => i.status !== 'done').length;
return (
<Link
key={t.theme}
to={href({ theme: t.theme === themeFilter ? null : t.theme })}
className={cn(
'rounded-full px-2.5 py-0.5 text-xs',
themeFilter === t.theme
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground',
)}
>
{t.heading.split('—')[0]?.trim()} · {open}
</Link>
);
})}
</div>
<div className="flex min-h-0 flex-1 gap-3 overflow-x-auto p-4">
{COLUMNS.map((col) => {
const cards = visible
.filter((i) => i.status === col.status)
.sort((a, b) => (b.priority === 'urgent' ? 1 : 0) - (a.priority === 'urgent' ? 1 : 0));
return (
<div
key={col.status}
onDragOver={(ev) => {
ev.preventDefault();
setDragOver(col.status);
}}
onDragLeave={() => setDragOver(null)}
onDrop={drop(col.status)}
className={cn(
'flex w-64 shrink-0 flex-col rounded-lg border border-border bg-muted/30',
dragOver === col.status && 'border-primary bg-primary/5',
)}
>
<div className="flex items-baseline justify-between px-3 py-2 text-xs font-semibold text-muted-foreground">
<span>{col.label}</span>
<span>{cards.length}</span>
</div>
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-2 pb-2">
{cards.map((item) => (
<TodoCard
key={item.id}
item={item}
href={href({ selected: item.id })}
selected={selected === item.id}
/>
))}
</div>
</div>
);
})}
</div>
</div>
{selected ? <TodoDetail id={selected} closeHref={href({ selected: null })} /> : null}
</div>
);
};
type TodoCardProps = { item: TodoItem; href: string; selected: boolean };
const TodoCard = ({ item, href, selected }: TodoCardProps) => (
<Link
to={href}
draggable
onDragStart={(ev) => ev.dataTransfer.setData('text/todo-id', item.id)}
className={cn(
'block rounded-md border border-border bg-card p-2.5 text-left shadow-sm hover:border-primary/50',
selected && 'border-primary',
)}
>
<div className="text-xs leading-snug text-card-foreground">{item.title}</div>
<div className="mt-1.5 flex flex-wrap items-center gap-1.5 text-[10px] text-muted-foreground">
<span className="rounded bg-muted px-1 py-px">{item.theme}</span>
{item.priority === 'urgent' && (
<span className="flex items-center gap-0.5 rounded bg-destructive/15 px-1 py-px text-destructive">
<AlertTriangle className="h-2.5 w-2.5" /> urgent
</span>
)}
{item.due && (
<span className="flex items-center gap-0.5">
<CalendarClock className="h-2.5 w-2.5" /> {item.due}
</span>
)}
<span>{item.created}</span>
</div>
</Link>
);
type TodoDetailProps = { id: string; closeHref: string };
const TodoDetail = ({ id, closeHref }: TodoDetailProps) => {
const { data, isLoading } = useTodoItem(id);
const patch = useTodoPatch();
const status = data?.meta.status ?? 'open';
return (
<div className="flex w-[28rem] shrink-0 flex-col border-l border-border">
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2">
<span className="truncate font-mono text-xs text-muted-foreground">{id}</span>
<div className="flex items-center gap-2">
<select
value={status}
onChange={(ev) => patch.mutate({ id, status: ev.target.value })}
className="rounded border border-border bg-background px-1.5 py-0.5 text-xs"
>
{COLUMNS.map((c) => (
<option key={c.status} value={c.status}>
{c.label}
</option>
))}
</select>
<Link to={closeHref} className="text-muted-foreground hover:text-foreground">
<X className="h-4 w-4" />
</Link>
</div>
</div>
<div className="file-viewer-md min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm">
{isLoading || !data ? (
<span className="text-sm text-muted-foreground">Reading…</span>
) : (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.body}</ReactMarkdown>
)}
</div>
</div>
);
};
@@ -1,10 +0,0 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { SquareKanban } from 'lucide-react';
import { TodoBoardPanel } from './TodoBoardPanel';
export { TodoBoardPanel } from './TodoBoardPanel';
export { useTodoTree, useTodoItem, useTodoPatch } from './useTodos';
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'todo-board', name: 'Todo board', icon: SquareKanban, component: TodoBoardPanel },
];
@@ -1,52 +0,0 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
export type TodoItem = {
id: string;
title: string;
theme: string;
status: string;
created: string;
priority: string;
due: string;
order: number;
file: string;
};
export type TodoTheme = { theme: string; heading: string; note: string; items: TodoItem[] };
export type TodoPatch = { id: string; status?: string; priority?: string; due?: string; order?: number };
export const useTodoTree = () => {
const client = useClient();
return useQuery({
queryKey: ['todos'],
queryFn: () => client.get<{ themes: TodoTheme[] }>('/todos'),
// The tree is files on disk that other agents edit while the board is open; refetch keeps the board
// honest without a watcher, which the sync contract allows (todo/README.md — poll is the cheapest
// thing that works).
refetchInterval: 30_000,
});
};
export const useTodoItem = (id: string | null) => {
const client = useClient();
return useQuery({
queryKey: ['todos', 'item', id],
queryFn: () =>
client.get<{ meta: Record<string, string>; body: string }>(`/todos/item?id=${encodeURIComponent(id!)}`),
enabled: id !== null,
});
};
export const useTodoPatch = () => {
const client = useClient();
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch: TodoPatch) => client.patch<{ ok: boolean }>('/todos/item', patch),
onSuccess: (_data, patch) => {
void queryClient.invalidateQueries({ queryKey: ['todos'] });
void queryClient.invalidateQueries({ queryKey: ['todos', 'item', patch.id] });
},
});
};
@@ -0,0 +1,201 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronRight, CornerLeftUp, Folder, FolderPlus, Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
// The platform's own formatter rather than fofos-admin's, which knew about that plugin's three
// specific failures. Here the only failures are "cannot read that folder" and "not allowed".
import { errorText } from 'helpers/error-text';
/**
* Pick a directory, and make one if it is not there yet.
*
* ── Shared, because two plugins wanted it and a plugin cannot import from another ──
*
* Written inside fofos-admin, then wanted verbatim by kanban's project form. A plugin cannot import
* from a sibling plugin, so the choice was to copy it or to move it here — and there is nothing
* fofos-specific in it: it browses the owner's home over the platform's own file API. Copying would
* have meant two of them drifting apart, which for a component whose whole job is "do not let somebody
* type a path wrong" is the wrong kind of thing to have two of.
*
* ── It uses the PLATFORM's file API, not routes of any plugin's own ──
*
* `/api/file-browser/ls` and `/api/file-browser/mkdir` already exist, already refuse to leave the
* home directory, and already distinguish "missing" from "not allowed" — a distinction that cost
* somebody an afternoon to get right and would have been re-guessed here. A plugin that shipped its
* own listing and mkdir would be a second filesystem surface to audit for the sake of one form.
*
* The consequence is worth stating: this browses the OWNER'S HOME and nothing above it. The file API
* is an execution capability and never grantable, so for anybody but the owner these calls 403 — the
* component says so instead of showing an empty tree, because an empty listing is data and must never
* be how a refusal looks.
*
* A path outside home is still reachable by TYPING it in the field this sits under. The picker is a
* convenience over the common case, not the only way in.
*
* ── `rootDir` comes from the server, so the full path is real ──
*
* `ls` answers with the absolute `rootDir` it resolved as well as the relative path inside it. Joining
* those two is what lets this hand back an absolute path without the browser ever guessing at what
* home means — which it cannot know and would get wrong for anybody but the owner.
*/
type Entry = { name: string; type: 'directory' | 'file' };
type Listing = { path: string; rootDir?: string; entries: Entry[] };
/** `/a/b` under `/home/x` → `/home/x/a/b`. The root itself is `/`, which must not become a trailing slash. */
const absolute = (rootDir: string, path: string) => `${rootDir}${path === '/' ? '' : path}`.replace(/\/+$/, '') || '/';
export const DirectoryPicker = ({
startAt,
onPick,
onCancel,
}: {
/** Relative to home, e.g. `/fofos`. Where the browser opens. */
startAt?: string;
/** The chosen directory, ABSOLUTE. */
onPick: (absolutePath: string) => void;
onCancel: () => void;
}) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [path, setPath] = useState(startAt ?? '/');
const [listing, setListing] = useState<Listing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const load = useCallback(async (target: string) => {
setLoading(true);
setError(null);
try {
const result = await clientRef.current.get<Listing>(
`/file-browser/ls?root=home&path=${encodeURIComponent(target)}`,
);
setListing(result);
// `ls` resets to the root when a path has gone, and reports where it actually ended up. Following
// its answer rather than our request is what stops a stale path showing the wrong folder's contents.
setPath(result.path);
} catch (failure) {
setError(errorText(failure, 'could not read that folder'));
setListing(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(startAt ?? '/');
}, [load, startAt]);
const rootDir = listing?.rootDir ?? '';
const directories = (listing?.entries ?? []).filter((entry) => entry.type === 'directory');
const parent = path === '/' ? null : path.slice(0, path.lastIndexOf('/')) || '/';
const create = async () => {
const name = newName.trim();
if (!name) return;
setError(null);
try {
const target = path === '/' ? `/${name}` : `${path}/${name}`;
await clientRef.current.post('/file-browser/mkdir?root=home', { path: target });
setNewName('');
setCreating(false);
// Straight into it. Making a folder here means meaning to use it, and stopping at its parent
// makes somebody find what they just made in a list.
await load(target);
} catch (failure) {
setError(errorText(failure, 'could not read that folder'));
}
};
return (
<div className="flex flex-col gap-2 rounded-md border border-border">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate font-mono text-xs">{absolute(rootDir, path)}</span>
{loading && <Loader2 className="size-3.5 shrink-0 animate-spin text-muted-foreground" />}
</div>
<div className="max-h-56 overflow-auto px-1">
{parent !== null && (
<button
onClick={() => void load(parent)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
>
<CornerLeftUp className="size-3.5 shrink-0 text-muted-foreground" />
Up one level
</button>
)}
{directories.map((entry) => (
<button
key={entry.name}
onClick={() => void load(path === '/' ? `/${entry.name}` : `${path}/${entry.name}`)}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-muted/50"
>
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/60" />
</button>
))}
{/* Only when there is genuinely nothing, and worded as a fact about this folder. Files are not
listed at all — this picks a directory, and showing them would be a list of things that
cannot be chosen. */}
{!loading && !error && directories.length === 0 && (
<p className="px-2 py-2 text-xs text-muted-foreground">No folders here.</p>
)}
</div>
{error && <p className="px-3 text-xs text-destructive">{error}</p>}
<div className="flex flex-wrap items-center gap-2 border-t border-border px-3 py-2">
{creating ? (
<>
<Input
autoFocus
value={newName}
onChange={(event) => setNewName(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void create();
if (event.key === 'Escape') setCreating(false);
}}
placeholder="New folder name"
className="h-7 min-w-0 flex-1 text-xs"
autoComplete="off"
spellCheck={false}
/>
<Button size="sm" className="h-7" onClick={() => void create()} disabled={!newName.trim()}>
Create
</Button>
<Button variant="ghost" size="sm" className="h-7" onClick={() => setCreating(false)}>
Cancel
</Button>
</>
) : (
<>
<Button variant="outline" size="sm" className="h-7" onClick={() => setCreating(true)}>
<FolderPlus className="size-3.5" />
New folder
</Button>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7" onClick={onCancel}>
Cancel
</Button>
{/* Chooses the folder currently OPEN, not one highlighted in the list. Opening a folder to
look inside it and then choosing it is one gesture rather than two, and it is the only
way to choose a folder that has nothing in it yet — which a freshly made one always is. */}
<Button size="sm" className="h-7" disabled={!rootDir} onClick={() => onPick(absolute(rootDir, path))}>
Use this folder
</Button>
</>
)}
</div>
</div>
);
};
+2
View File
@@ -6,6 +6,8 @@ export { panelCatalogs } from './locales';
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
export type { PageTitleOverride } from './page-title';
export * from './AppRegistry';
/** Shared UI a plugin can use. `DirectoryPicker` is here because two plugins wanted the same one. */
export { DirectoryPicker } from './components/DirectoryPicker';
export * from './WidgetRegistry';
// Re-export app modules (excluding appRegistryMetas to avoid name collisions)