Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
## Themes
|
||||
|
||||
**107 open, 32 closed**, across 34 themes.
|
||||
**106 open, 33 closed**, across 34 themes.
|
||||
|
||||
| Theme | Open | Closed |
|
||||
| --- | --- | --- |
|
||||
@@ -45,7 +45,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
| [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 |
|
||||
|
||||
@@ -122,6 +122,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:*",
|
||||
@@ -365,7 +366,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 +444,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 +488,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=="],
|
||||
|
||||
@@ -2135,7 +2150,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 +2162,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=="],
|
||||
|
||||
@@ -2419,6 +2434,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 +2548,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 +2588,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 +2630,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 +2748,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 +2960,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,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.
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.27.02",
|
||||
"version": "2026.08.29.03",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
@@ -160,6 +160,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:*",
|
||||
|
||||
@@ -60,9 +60,34 @@ 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()]);
|
||||
|
||||
@@ -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: >-');
|
||||
|
||||
@@ -69,14 +69,21 @@ const git = (dir: string, args: string[]): string | 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.
|
||||
There is one: `platform/plugins/`, holding what this machine has INSTALLED — updated by
|
||||
`plugins:update`, which is a git pull, so it sits at whatever version was last installed.
|
||||
|
||||
Publishing from 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.
|
||||
Publishing from it means the catalogue advertises the version this machine happens to be running
|
||||
rather than the version that was released. That is exactly what happened on 2026-08-27: 21 plugins
|
||||
were tagged and the store went on showing 1.0.0.
|
||||
|
||||
A second directory — `officer-dev/plugins/`, every repo in the org cloned side by side — was created
|
||||
to fix that, and never did: its entries were SYMLINKS into this one, so both paths read the same
|
||||
working copy and published the same version. It was removed on 2026-08-28.
|
||||
|
||||
**The problem is still open, and a second checkout was never the answer.** Each directory here is a
|
||||
real git repo with its own tags, so the released manifest can be read out of the tag itself —
|
||||
`git show <newest-tag>:manifest.ts` — without caring what is checked out. That is the fix; it is not
|
||||
written yet, and until it is, this script advertises what is installed.
|
||||
*/
|
||||
const PLUGINS_DIR = process.env.PLUGINS_DIR ?? resolve(import.meta.dir, '..', 'plugins');
|
||||
|
||||
|
||||
@@ -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"; }
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -201,6 +203,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 +235,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 +256,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 +279,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 +331,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 +346,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 +523,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 +587,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 +612,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 +635,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}`);
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -9,6 +9,8 @@ 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';
|
||||
|
||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
try {
|
||||
@@ -203,6 +205,12 @@ 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) => {
|
||||
const fullPath = resolve(absPath, name);
|
||||
@@ -210,11 +218,35 @@ router.get('/ls', async (ctx) => {
|
||||
if (!isInside(rootDir, fullPath)) return null;
|
||||
const s = await stat(fullPath).catch(() => null);
|
||||
if (!s) return null;
|
||||
const isDirectory = s.isDirectory();
|
||||
return {
|
||||
name,
|
||||
type: s.isDirectory() ? 'directory' : 'file',
|
||||
type: isDirectory ? 'directory' : 'file',
|
||||
size: s.size,
|
||||
modifiedAt: s.mtimeMs,
|
||||
/*
|
||||
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)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -333,6 +365,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);
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
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.
|
||||
*/
|
||||
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 };
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import { snapshotPlugins } from '../../plugins/mount';
|
||||
import { manualInstallHint, reportDependencies } from '../../plugins/os-deps';
|
||||
import { fetchCatalogue, fetchReadme } 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,
|
||||
@@ -224,6 +227,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);
|
||||
|
||||
@@ -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'] = [];
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}"` };
|
||||
}
|
||||
|
||||
@@ -477,6 +477,38 @@ 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;
|
||||
};
|
||||
|
||||
osDependencies?: OsDependency[];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Package, Plus } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'officerdev';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePluginCommanderTemplates } from './useCommandHq';
|
||||
import type { PluginCommanderTemplate } from './types';
|
||||
|
||||
/**
|
||||
* What a plugin offers but has not been started.
|
||||
*
|
||||
* ── Offers sit ABOVE the grid, not inside it ──
|
||||
*
|
||||
* A commander in the grid is running, has a transcript and has cost something. An offer is a
|
||||
* description on disk. Drawing them as the same kind of card, distinguished by a badge, makes the two
|
||||
* look interchangeable — and the first consequence is somebody looking for the transcript of a thing
|
||||
* that has never run.
|
||||
*
|
||||
* ── An offer stays visible after it has been used ──
|
||||
*
|
||||
* Deliberately: a plugin's commander is a template, and there is no reason to have only one. Watching
|
||||
* two different folders for two different formats is two commanders from the same offer, and hiding
|
||||
* the offer once used would make the second one look impossible.
|
||||
*/
|
||||
|
||||
export const PluginCommanderOffers = ({ plugin }: { plugin: string }) => {
|
||||
const { t } = useTranslation();
|
||||
const { templates, createFromTemplate } = usePluginCommanderTemplates();
|
||||
const [configuring, setConfiguring] = useState<PluginCommanderTemplate | null>(null);
|
||||
|
||||
const offers = templates.filter((template) => template.plugin === plugin);
|
||||
if (offers.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||
<Package className="size-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-medium tracking-wide text-muted-foreground uppercase">
|
||||
{t('commandHq.available')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 pt-1">
|
||||
{offers.map((offer) => (
|
||||
<div key={offer.dirName} className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{offer.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{offer.description}</p>
|
||||
{offer.watches && <p className="pt-0.5 text-xs text-muted-foreground/70">{offer.watches}</p>}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={configuring?.dirName === offer.dirName ? 'secondary' : 'outline'}
|
||||
className="shrink-0"
|
||||
onClick={() => setConfiguring(configuring?.dirName === offer.dirName ? null : offer)}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t('commandHq.configureAndCreate')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{configuring?.dirName === offer.dirName && (
|
||||
<ConfigureForm
|
||||
template={offer}
|
||||
busy={createFromTemplate.isPending}
|
||||
onCancel={() => setConfiguring(null)}
|
||||
onSubmit={(name, config) => {
|
||||
createFromTemplate.mutate(
|
||||
{ plugin: offer.plugin, template: offer.dirName, name, config },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setConfiguring(null);
|
||||
// Named, because it is now in the grid below among things that look alike.
|
||||
toast.success(`${name} created — it is not running yet`);
|
||||
},
|
||||
onError: (err: unknown) =>
|
||||
toast.error(err instanceof Error ? err.message : 'could not create it'),
|
||||
},
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The template's own inputs, drawn from its declaration.
|
||||
*
|
||||
* Nothing here knows what av-club's commander wants. `options` becomes a select, `boolean` becomes
|
||||
* yes/no, everything else is a text field — the same three shapes a task's inputs use, because they
|
||||
* are the same declaration.
|
||||
*/
|
||||
const ConfigureForm = ({
|
||||
template,
|
||||
busy,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: {
|
||||
template: PluginCommanderTemplate;
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (name: string, config: Record<string, string>) => void;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(template.name);
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(Object.entries(template.inputs).map(([key, spec]) => [key, spec.default ?? ''])),
|
||||
);
|
||||
|
||||
const missing = Object.entries(template.inputs)
|
||||
.filter(([key, spec]) => spec.required && !values[key]?.trim())
|
||||
.map(([key]) => key);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mt-3 flex flex-col gap-3 border-t border-border pt-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (missing.length === 0 && name.trim()) onSubmit(name.trim(), values);
|
||||
}}
|
||||
>
|
||||
<label className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('commandHq.name')}</span>
|
||||
<Input value={name} onChange={(event) => setName(event.target.value)} className="text-sm" />
|
||||
</label>
|
||||
|
||||
{Object.entries(template.inputs).map(([key, spec]) => (
|
||||
<label key={key} className="flex flex-col gap-1">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{key.replace(/_/g, ' ')}
|
||||
{spec.required && <span className="text-destructive"> *</span>}
|
||||
</span>
|
||||
|
||||
{spec.options ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{spec.options.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
values[key] === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : spec.type === 'boolean' ? (
|
||||
<div className="flex gap-1">
|
||||
{['false', 'true'].map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({ ...v, [key]: option }))}
|
||||
className={`rounded-md px-2.5 py-1 text-xs transition-colors ${
|
||||
(values[key] || 'false') === option ? 'bg-primary/15 text-primary' : 'bg-muted/50 hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
{option === 'true' ? 'Yes' : 'No'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
value={values[key] ?? ''}
|
||||
onChange={(event) => setValues((v) => ({ ...v, [key]: event.target.value }))}
|
||||
placeholder={spec.type === 'directory' ? '/home/you/folder' : ''}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{spec.description && <span className="text-[11px] text-muted-foreground/70">{spec.description}</span>}
|
||||
</label>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" size="sm" disabled={busy || missing.length > 0 || !name.trim()}>
|
||||
{busy && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{t('commandHq.configureAndCreate')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
{/* Named, so it is obvious WHICH field is stopping the button rather than hunting for a red mark. */}
|
||||
{missing.length > 0 && (
|
||||
<span className="text-xs text-muted-foreground">needs {missing.join(', ').replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -40,6 +40,9 @@ export const commandHqSessionPath = (sessionId: string, category?: CommandHqCate
|
||||
export const categoryFromParam = (raw: string | null): CommandHqCategoryFilter => {
|
||||
if (raw === null) return undefined;
|
||||
if (raw === 'none') return 'none';
|
||||
// `plugin:av-club`. Checked before the numeric parse, since an appName never parses as a number and
|
||||
// would otherwise fall through to `undefined` and silently show every commander.
|
||||
if (raw.startsWith('plugin:') && raw.length > 'plugin:'.length) return raw as CommandHqCategoryFilter;
|
||||
const id = Number(raw);
|
||||
return Number.isFinite(id) ? id : undefined;
|
||||
};
|
||||
|
||||
@@ -26,8 +26,16 @@ export type CommandHqCostSummary = {
|
||||
totalUsd: number;
|
||||
};
|
||||
|
||||
/** `category` query value: omitted (all), `'none'` (uncategorised), or a category id. */
|
||||
export type CommandHqCategoryFilter = number | 'none' | undefined;
|
||||
/**
|
||||
* `category` query value: omitted (all), `'none'` (uncategorised), a category id, or `plugin:<appName>`.
|
||||
*
|
||||
* The plugin form is not a category and deliberately does not pretend to be one. A commander's category
|
||||
* is the owner's filing; which plugin shipped it is a fact about where it came from, and the two are
|
||||
* independent — a plugin's commander can also be filed under "Nightly" without either meaning changing.
|
||||
* One query parameter carries both because they are both "which subset of the grid", and that is what
|
||||
* this parameter has always meant.
|
||||
*/
|
||||
export type CommandHqCategoryFilter = number | 'none' | `plugin:${string}` | undefined;
|
||||
|
||||
/** One grid card — `ClaudeSessionSummary` plus what Command HQ knows about it. See `GET /commanders`. */
|
||||
export type CommandHqCommander = {
|
||||
@@ -100,3 +108,23 @@ export type CommandHqFire = {
|
||||
taskId: string;
|
||||
firedAt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A commander a plugin OFFERS but which does not exist yet.
|
||||
*
|
||||
* Deliberately a different type from `CommandHqCommander`, and fetched from a different endpoint. One
|
||||
* runs, has a transcript and costs money; the other is a description on disk. Modelling them as one
|
||||
* type with an `exists` flag would mean every consumer checking the flag before touching anything, and
|
||||
* the one that forgets renders a card with a cost of zero for something that was never started.
|
||||
*/
|
||||
export type PluginCommanderTemplate = {
|
||||
dirName: string;
|
||||
plugin: string;
|
||||
name: string;
|
||||
description: string;
|
||||
watches?: string;
|
||||
inputs: Record<
|
||||
string,
|
||||
{ type: string; description?: string; default?: string; options?: string[]; required?: boolean }
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CommandHqCommander,
|
||||
CommandHqCommanderCreated,
|
||||
CommandHqCommanderDetail,
|
||||
PluginCommanderTemplate,
|
||||
} from './types';
|
||||
|
||||
const CATEGORIES_KEY = ['command-hq', 'categories'] as const;
|
||||
@@ -173,3 +174,36 @@ export function useCommandHqChildren(sessionId: string | undefined) {
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the installed plugins offer, and making one real.
|
||||
*
|
||||
* The list is cheap and changes only when a plugin is installed, updated or disabled — so it is not
|
||||
* refetched on a window focus. A plugin the owner just installed appears when Command HQ next mounts,
|
||||
* which is the same moment its dock tile does.
|
||||
*/
|
||||
export const usePluginCommanderTemplates = () => {
|
||||
const { get, post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data, isLoading } = useQuery<{ templates: PluginCommanderTemplate[] }>({
|
||||
queryKey: ['command-hq', 'plugin-commanders'],
|
||||
queryFn: () => get('/command-hq/plugin-commanders'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const createFromTemplate = useMutation({
|
||||
mutationFn: (input: { plugin: string; template: string; name: string; config: Record<string, string> }) =>
|
||||
post<CommandHqCommanderCreated>('/command-hq/commanders', input),
|
||||
// The grid reads `commanders`, and a newly created one has to appear in it without a reload —
|
||||
// creating from a template is the one moment somebody is certain something should have happened.
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['command-hq', 'commanders'] }),
|
||||
});
|
||||
|
||||
return { templates: data?.templates ?? EMPTY_TEMPLATES, isLoading, createFromTemplate };
|
||||
};
|
||||
|
||||
// Module-level, so an empty result is the SAME array every render. A fresh `[]` in the fallback is a new
|
||||
// identity each time, which re-runs every effect and memo that depends on it — the trap this file already
|
||||
// avoids for categories and commanders.
|
||||
const EMPTY_TEMPLATES: PluginCommanderTemplate[] = [];
|
||||
|
||||
@@ -53,6 +53,7 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
visibleEntries: entries,
|
||||
viewMode,
|
||||
currentPath,
|
||||
rootOverride,
|
||||
selected,
|
||||
clipboard,
|
||||
handleOpen,
|
||||
@@ -236,6 +237,8 @@ export const FileGrid = ({ fileBrowserManager }: FileGridProps) => {
|
||||
key={entry.name}
|
||||
entry={entry}
|
||||
viewMode={viewMode}
|
||||
path={entryPath(entry.name)}
|
||||
root={rootOverride}
|
||||
selected={selected.has(entry.name)}
|
||||
anySelected={anySelected}
|
||||
selectedCount={selected.size}
|
||||
|
||||
+40
-2
@@ -25,6 +25,7 @@ import {
|
||||
PinOff,
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { Thumbnail } from './Thumbnail';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -56,6 +57,10 @@ import { useFormat } from 'i18n';
|
||||
export type FileItemProps = {
|
||||
entry: DirEntry;
|
||||
viewMode: 'grid' | 'list';
|
||||
/** Root-relative path of this entry, for the thumbnail URL. */
|
||||
path: string;
|
||||
/** The browser's root, when it is not `home`. */
|
||||
root?: string;
|
||||
selected: boolean;
|
||||
anySelected: boolean;
|
||||
selectedCount: number;
|
||||
@@ -587,6 +592,8 @@ export const FileItem = ({
|
||||
onTogglePin,
|
||||
isPinned,
|
||||
canPaste,
|
||||
path,
|
||||
root,
|
||||
}: FileItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { date } = useFormat();
|
||||
@@ -605,6 +612,37 @@ export const FileItem = ({
|
||||
<MaterialFileIcon name={entry.name} className="inline-flex h-10 w-10" />
|
||||
);
|
||||
|
||||
/*
|
||||
The picture, where there is one, wrapping the icon it replaces.
|
||||
|
||||
`entry.thumb` comes from the server — see the note on it in useFilesAPI. Both views ask for a size
|
||||
a little larger than they draw so a retina screen is not resampling upwards, and both fall back to
|
||||
exactly the icon above when there is no picture to be had.
|
||||
|
||||
Rounded in the list and square in the grid: the list row is a line of text with a small image in it,
|
||||
where a soft edge sits better beside a glyph; the grid tile is the picture, and cropping it into a
|
||||
rounded box would trim content off a photograph for decoration.
|
||||
*/
|
||||
const withThumb = (kind: 'image' | 'video', node: React.ReactNode, className: string, size: number) => (
|
||||
<Thumbnail path={path} root={root} size={size} isVideo={kind === 'video'} className={className}>
|
||||
{node}
|
||||
</Thumbnail>
|
||||
);
|
||||
|
||||
/*
|
||||
ONE SIZE FOR BOTH VIEWS, and it is 256 rather than the 128 the list used to ask for.
|
||||
|
||||
The size is part of the cache key, so two sizes meant two entries — and switching from grid to list
|
||||
re-rendered every picture from scratch. For a jpeg that is a few milliseconds nobody notices. For a
|
||||
HEIC, which a plugin decodes, it is four seconds per file: the list showed icons where the grid had
|
||||
just shown photographs, and the two views appeared to disagree about the same file.
|
||||
|
||||
The list draws it at 28px and the grid at about 80, so both are downscaling either way and 256 is
|
||||
sharp enough for a retina screen at both. One key, one render, and the views cannot diverge.
|
||||
*/
|
||||
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 256) : icon;
|
||||
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (clickTimer.current) clearTimeout(clickTimer.current);
|
||||
@@ -708,7 +746,7 @@ export const FileItem = ({
|
||||
onPointerDown={(ev) => ev.stopPropagation()}
|
||||
>
|
||||
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
|
||||
<span className="shrink-0">{icon}</span>
|
||||
<span className="shrink-0">{listVisual}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
{renaming ? (
|
||||
<InlineRenameInput
|
||||
@@ -762,7 +800,7 @@ export const FileItem = ({
|
||||
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
|
||||
</div>
|
||||
|
||||
{iconLarge}
|
||||
{gridVisual}
|
||||
|
||||
{renaming ? (
|
||||
<InlineRenameInput
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Play } from 'lucide-react';
|
||||
import { getHeaders } from 'hooks/useClient';
|
||||
|
||||
// Matches , which declares its own for the same reason: one string, and no
|
||||
// import cycle between the browser and the viewer.
|
||||
const API_URL = '/api';
|
||||
|
||||
/**
|
||||
* The picture of a file, where there is one — an image, or a frame from a video.
|
||||
*
|
||||
* ── One component for both views ──
|
||||
*
|
||||
* The grid and the list want the same thing at two sizes, so the only difference between them is a
|
||||
* `size` and a class. Two implementations would have drifted the first time one of them learned
|
||||
* something the other did not, and the fallback behaviour below is the part that would have drifted.
|
||||
*
|
||||
* ── It falls back to the icon, and the icon is the CHILD ──
|
||||
*
|
||||
* `children` is whatever the item would have drawn without a thumbnail. It is rendered until the image
|
||||
* loads and again forever if it fails, so a file that cannot be thumbnailed — a codec ffmpeg does not
|
||||
* have, a corrupt download, a member without file access — looks exactly like it did before this
|
||||
* existed rather than like a hole in the layout.
|
||||
*
|
||||
* That is also why the icon is not merely hidden behind the image: an <img> that 404s draws a broken
|
||||
* glyph in most browsers, and a layout that reserves space for a picture that never comes reads as a
|
||||
* page still loading.
|
||||
*
|
||||
* ── `loading="lazy"`, on top of virtualisation ──
|
||||
*
|
||||
* The grid only mounts what is on screen, so most of the work is already avoided. Lazy loading covers
|
||||
* the rest: the rows just past the fold are mounted by the virtualiser's overscan, and without this
|
||||
* every one of them would spawn an ffmpeg for something nobody has looked at.
|
||||
*/
|
||||
|
||||
/*
|
||||
How many times to try before settling for the icon, and how long to wait between.
|
||||
|
||||
A thumbnail can legitimately fail for a REASON THAT PASSES: the plugin that renders it is restarting,
|
||||
the server is busy behind its concurrency cap, or — for a format that needs decoding — the first
|
||||
request is simply slow enough that something upstream gave up. Treating the first failure as final
|
||||
meant a folder of HEIC came back as file icons and stayed that way until somebody reloaded the page,
|
||||
which is what made it look like a race rather than a retry that never happened.
|
||||
|
||||
Backing off rather than hammering: a genuinely undecodable file must not become a request loop, and
|
||||
three attempts over about seven seconds is enough to outlast a sidecar restart without becoming one.
|
||||
*/
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const RETRY_DELAYS_MS = [1200, 3000, 6000];
|
||||
|
||||
export const Thumbnail = ({
|
||||
path,
|
||||
root,
|
||||
size,
|
||||
isVideo,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
/** Root-relative, the same path every other file-browser call uses. */
|
||||
path: string;
|
||||
root?: string;
|
||||
/** What to ask the server for. Snapped server-side, so a near-enough number is fine. */
|
||||
size: number;
|
||||
/** Draws the play badge. The thumbnail of a video is a still, and a still is not obviously a video. */
|
||||
isVideo: boolean;
|
||||
className?: string;
|
||||
/** The file icon — shown until the image arrives, and kept if it never does. */
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading');
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// A pending retry must not fire into an unmounted row — the grid is virtualised, so scrolling past a
|
||||
// slow thumbnail unmounts it mid-wait.
|
||||
useEffect(() => () => clearTimeout(timer.current ?? undefined), []);
|
||||
|
||||
if (state === 'failed') return <>{children}</>;
|
||||
|
||||
const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||
const src = `${API_URL}/file-browser/thumb?path=${encodeURIComponent(path)}&size=${size}&token=${encodeURIComponent(token)}${rootParam}`;
|
||||
|
||||
return (
|
||||
<span className={`relative inline-flex items-center justify-center overflow-hidden ${className ?? ''}`}>
|
||||
{/* Underneath rather than instead of. Swapping on load would reflow the row twice per file on a
|
||||
fast connection, which is visible as a shudder while a folder fills in. */}
|
||||
{state === 'loading' && <span className="absolute inset-0 flex items-center justify-center">{children}</span>}
|
||||
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
// `attempt` is in the key, so a retry REMOUNTS the element. Changing only the src would leave
|
||||
// the browser's own failed-request cache in play and the second attempt would not be one.
|
||||
key={attempt}
|
||||
onLoad={() => setState('ready')}
|
||||
onError={() => {
|
||||
if (attempt + 1 >= MAX_ATTEMPTS) {
|
||||
setState('failed');
|
||||
return;
|
||||
}
|
||||
timer.current = setTimeout(() => setAttempt((n) => n + 1), RETRY_DELAYS_MS[attempt] ?? 6000);
|
||||
}}
|
||||
className={`size-full object-cover transition-opacity duration-150 ${
|
||||
state === 'ready' ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Only once the frame is actually showing — a badge over a file icon would claim the icon is a
|
||||
video still, which is the one thing it is not. */}
|
||||
{state === 'ready' && isVideo && (
|
||||
<span className="pointer-events-none absolute right-0.5 bottom-0.5 rounded-sm bg-black/60 p-0.5">
|
||||
<Play className="size-2.5 fill-white text-white" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -1003,6 +1003,14 @@ export const useFileBrowserApp = (basePath: string, rootOverride?: string, urlPa
|
||||
user,
|
||||
// Navigation
|
||||
homeRoot,
|
||||
/*
|
||||
The root this browser is reading, when it is not the caller's home.
|
||||
|
||||
Already threaded into `useFilesAPI` and the viewer params above; exposed because anything building
|
||||
a URL to one of these files — a thumbnail, a download — needs the same answer, and reconstructing
|
||||
it from `homeRoot` gets the panel-with-an-override case wrong in exactly the way line 440 explains.
|
||||
*/
|
||||
rootOverride,
|
||||
currentPath,
|
||||
setCurrentPath,
|
||||
// Null when this browser does not own the address bar — the breadcrumb then stays buttons.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { Loader2, FolderArchive, FileWarning, Download } from 'lucide-react';
|
||||
import { getLang, getRawUrl, getArchiveBaseName } from './file-types';
|
||||
import { getLang, getRawUrl, getRenderUrl, needsServerRender, getArchiveBaseName } from './file-types';
|
||||
import { useFileViewer } from './FileViewerContext';
|
||||
import { PdfRenderer } from './renderers/PdfRenderer';
|
||||
import { ImageRenderer } from './renderers/ImageRenderer';
|
||||
@@ -63,7 +63,15 @@ export const FileViewerBody = () => {
|
||||
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
||||
</div>
|
||||
) : fileType === 'image' ? (
|
||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
||||
<ImageRenderer
|
||||
/*
|
||||
HEIC and friends go through the server, because no browser decodes them. Everything else
|
||||
keeps using `/raw`: it streams, supports ranges, and lets the browser paint progressively —
|
||||
all of which a rendered copy gives up.
|
||||
*/
|
||||
src={needsServerRender(fileName) ? getRenderUrl(filePath, root) : getRawUrl(filePath, root)}
|
||||
fileName={fileName}
|
||||
/>
|
||||
) : fileType === 'video' ? (
|
||||
<VideoRenderer src={videoSrc} fileName={fileName} filePath={filePath} root={root} />
|
||||
) : fileType === 'audio' ? (
|
||||
|
||||
@@ -124,6 +124,34 @@ export function formatTime(s: number): string {
|
||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image formats NO browser can display, whatever the server does.
|
||||
*
|
||||
* This is a fact about browsers, not about plugins — which is why it is a constant here rather than
|
||||
* something fetched. Chrome and Firefox have never decoded HEIC and Safari only does on Apple's own
|
||||
* platforms, so a `<img src>` pointed at one shows a broken image everywhere it matters.
|
||||
*
|
||||
* The server may or may not be able to render one into something showable; that depends on whether a
|
||||
* plugin claims the type, and `/file-browser/render` answers 415 when nothing does.
|
||||
*/
|
||||
export const BROWSER_CANNOT_DISPLAY = ['heic', 'heif'];
|
||||
|
||||
export const needsServerRender = (name: string): boolean =>
|
||||
BROWSER_CANNOT_DISPLAY.includes(name.split('.').pop()?.toLowerCase() ?? '');
|
||||
|
||||
/**
|
||||
* A displayable version of a file the browser cannot decode. 415 when nothing installed can make one.
|
||||
*
|
||||
* `width` is what the viewer will draw at, doubled for retina and clamped server-side — asking for the
|
||||
* full 4032px of a phone photograph to show it in a panel is megabytes nobody looks at.
|
||||
*/
|
||||
export function getRenderUrl(filePath: string, root?: string, width = 2048): string {
|
||||
const headers = getHeaders();
|
||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||
return `${API_URL}/file-browser/render?path=${encodeURIComponent(filePath)}&width=${width}&token=${encodeURIComponent(token)}${rootParam}`;
|
||||
}
|
||||
|
||||
export function getRawUrl(filePath: string, root?: string, audioTrack?: number): string {
|
||||
const headers = getHeaders();
|
||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { UserCog, LogOut } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { isImpersonating, endImpersonation } from './impersonation';
|
||||
|
||||
/**
|
||||
* A bar across the top, for as long as you are somebody else.
|
||||
*
|
||||
* Loud on purpose. The failure this prevents is not a security hole, it is a person forgetting — reading
|
||||
* a mailbox, sending a message or deleting something believing it is theirs. Every action taken here is
|
||||
* recorded against the account being worn, so the only defence is that you cannot miss it.
|
||||
*
|
||||
* It asks the server who it is rather than decoding the token: the token is the thing being tested, and a
|
||||
* banner that reads its own claims would keep showing a name after the hour ran out.
|
||||
*/
|
||||
export const ImpersonationBanner = () => {
|
||||
const client = useClient();
|
||||
const active = isImpersonating();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['auth', 'me', 'impersonation'],
|
||||
queryFn: () => client.get<{ email?: string; name?: string }>('/auth/me'),
|
||||
enabled: active,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-center gap-3 bg-amber-500 px-4 py-1.5 text-xs font-medium text-black">
|
||||
<UserCog className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
You are signed in as <b>{data?.name ?? data?.email ?? 'another account'}</b>. Everything you do is
|
||||
recorded as theirs.
|
||||
</span>
|
||||
<button
|
||||
onClick={endImpersonation}
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-black/15 px-2 py-0.5 transition-colors hover:bg-black/25"
|
||||
>
|
||||
<LogOut className="h-3 w-3" />
|
||||
Back to yourself
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Being somebody else, and getting back.
|
||||
*
|
||||
* ── Why the way back is a stashed token and not a server route ──
|
||||
*
|
||||
* Returning to yourself is putting back the token you already had. There is no server state to expire,
|
||||
* leak or get wrong, and no route that mints an owner token — which would be a far more dangerous thing
|
||||
* to own than this whole feature. The cost is that clearing browser storage strands you as the other
|
||||
* person until their hour runs out; signing in again fixes it, and that is the right trade against a
|
||||
* route that hands out owner sessions.
|
||||
*
|
||||
* ── Why `localStorage` and not `sessionStorage` ──
|
||||
*
|
||||
* The same storage the client reads its token from, so the two cannot disagree, and so a second tab
|
||||
* opened mid-support sees the same account rather than silently being the owner.
|
||||
*/
|
||||
|
||||
const TOKEN_KEYS = ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN'] as const;
|
||||
/** Where the owner's own token waits. Distinct from every key the client reads, so it is never picked up. */
|
||||
const STASH_KEY = 'officer:impersonation:owner-token';
|
||||
|
||||
const writeToken = (token: string) => {
|
||||
for (const key of TOKEN_KEYS) localStorage.setItem(key, token);
|
||||
(window as { officerBearerToken?: string }).officerBearerToken = token;
|
||||
};
|
||||
|
||||
/** True while this browser is wearing somebody else's session. */
|
||||
export const isImpersonating = (): boolean => Boolean(localStorage.getItem(STASH_KEY));
|
||||
|
||||
/**
|
||||
* Swap to the target's token, keeping your own.
|
||||
*
|
||||
* A full reload rather than a re-render: every query in flight, every socket and every cached page
|
||||
* belongs to the previous account. Refetching them piecemeal is how a support view ends up showing two
|
||||
* people's data at once.
|
||||
*/
|
||||
export const startImpersonation = (token: string) => {
|
||||
const mine = localStorage.getItem('PERTENTO_EDITOR_AUTH_TOKEN') ?? localStorage.getItem('BEARER_TOKEN');
|
||||
// Refuse rather than stash nothing: without the owner's token there is no way back, and discovering
|
||||
// that after the swap is the worst possible moment.
|
||||
if (!mine) throw new Error('Could not find your own session to return to');
|
||||
// Never overwrite an existing stash — that would be a second hop overwriting the way home.
|
||||
if (!localStorage.getItem(STASH_KEY)) localStorage.setItem(STASH_KEY, mine);
|
||||
writeToken(token);
|
||||
window.location.assign('/');
|
||||
};
|
||||
|
||||
/** Put your own token back and reload. Safe to call when not impersonating: it does nothing. */
|
||||
export const endImpersonation = () => {
|
||||
const mine = localStorage.getItem(STASH_KEY);
|
||||
if (!mine) return;
|
||||
localStorage.removeItem(STASH_KEY);
|
||||
writeToken(mine);
|
||||
window.location.assign('/');
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useState } from 'react';
|
||||
import { GitBranch, X, Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* "Install from Git repository" — for a plugin that is in no catalogue and should not be.
|
||||
*
|
||||
* The marketplace was the only way a remote plugin could arrive, so "build one and give it to a friend"
|
||||
* had no affordance. It is also the wrong shape for a plugin built for one person: fofos-admin is one
|
||||
* website's admin panel, and listing it publicly in order to install it would be a lie about who it is for.
|
||||
*
|
||||
* ── It FETCHES. It does not install ──
|
||||
*
|
||||
* The first version installed, and using it showed why that was wrong: this button is on the grid, the
|
||||
* progress log is in the detail panel, so the install ran to completion with nothing on screen and the
|
||||
* plugin simply appeared, already installed. No feedback, and no moment to change your mind.
|
||||
*
|
||||
* Cloning is enough to make a plugin real — the platform lists what is on disk, so it shows up as
|
||||
* Available like any catalogue entry. You then click the card and install it there, with the progress
|
||||
* and the result the existing flow already renders. One install path, and it is the one people know.
|
||||
*
|
||||
* ── No name field ──
|
||||
*
|
||||
* The name is derived from the repository server-side, because `appName` IS the directory a plugin sits
|
||||
* in and decides its route, its table prefix and its process name. A box for it would invite a name that
|
||||
* disagrees with the address. What it became is reported back, and named in the confirmation.
|
||||
*/
|
||||
export const InstallFromUrl = ({
|
||||
onFetch,
|
||||
}: {
|
||||
onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }>;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* The outcome stays until the next attempt. A toast would be gone before somebody who just
|
||||
pasted a URL has looked up, and "which card is mine" is the question this answers. */}
|
||||
{note && <span className={note.ok ? 'text-duck-dark/50' : 'text-destructive'}>{note.text}</span>}
|
||||
<button
|
||||
onClick={() => {
|
||||
setNote(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md border border-duck-dark/10 px-2.5 py-1 text-xs text-duck-dark/60 transition-colors hover:bg-duck-dark/5 hover:text-duck-dark"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
Install from Git repository
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || busy) return;
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
const result = await onFetch(trimmed);
|
||||
setBusy(false);
|
||||
if (result.ok) {
|
||||
/*
|
||||
Named, because the name was derived rather than typed — this is where you find out what to look
|
||||
for. And it says which of the two things happened: a plugin whose source was deleted but whose
|
||||
install row survived comes back ALREADY installed, and telling somebody to "click it to install"
|
||||
while it sits in a different tab is worse than saying nothing.
|
||||
*/
|
||||
setNote({
|
||||
ok: true,
|
||||
text: result.installed
|
||||
? `${result.appName} added — already installed`
|
||||
: `${result.appName} added — click it to install`,
|
||||
});
|
||||
setOpen(false);
|
||||
setUrl('');
|
||||
} else {
|
||||
setNote({ ok: false, text: result.error ?? 'Could not fetch that repository' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{busy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-duck-dark/40" />
|
||||
) : (
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0 text-duck-dark/40" />
|
||||
)}
|
||||
<input
|
||||
autoFocus
|
||||
value={url}
|
||||
disabled={busy}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void submit();
|
||||
if (e.key === 'Escape' && !busy) setOpen(false);
|
||||
}}
|
||||
placeholder="https://gitea.officer.dev/owner/plugin.git"
|
||||
className="min-w-0 flex-1 rounded-md border border-duck-dark/10 bg-transparent px-2 py-1 font-mono text-xs outline-none focus:border-duck-dark/30 disabled:opacity-50"
|
||||
/>
|
||||
{note && !note.ok && <span className="shrink-0 text-destructive">{note.text}</span>}
|
||||
<button
|
||||
onClick={() => void submit()}
|
||||
disabled={!url.trim() || busy}
|
||||
className="shrink-0 rounded-md bg-duck-dark/10 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-duck-dark/20 disabled:opacity-40"
|
||||
>
|
||||
{busy ? 'Fetching…' : 'Add'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={busy}
|
||||
aria-label="Cancel"
|
||||
className="shrink-0 rounded p-1 text-duck-dark/40 hover:text-duck-dark disabled:opacity-40"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Check, Download, GitBranch, Store } from 'lucide-react';
|
||||
import { InstallFromUrl } from './InstallFromUrl';
|
||||
import type { AvailablePlugin, PluginItem } from './usePlugins';
|
||||
|
||||
/**
|
||||
* The Marketplace section: what this machine has, and what it could have.
|
||||
*
|
||||
* ── Three tabs, because there are genuinely three states ──
|
||||
*
|
||||
* `Installed` is what is running. The other two are both "not installed" and they are NOT the same
|
||||
* thing, which is the distinction the old single grid lost:
|
||||
*
|
||||
* `Official` the marketplace catalogue — somewhere else, one click and a download away.
|
||||
* `Available` already ON THIS MACHINE and not installed. A plugin fetched from a git URL lands
|
||||
* here, and so does one that was deleted without being purged.
|
||||
*
|
||||
* Collapsing those two hid the whole point of fetching from a repository: you add something, and it is
|
||||
* indistinguishable from twenty things you have not downloaded yet.
|
||||
*
|
||||
* ── Why the git field lives in Available ──
|
||||
*
|
||||
* That is the tab its result appears in. A button that adds something to the list you are looking at is
|
||||
* legible; the same button floating above a filtered grid was not, which is how the first version
|
||||
* managed to install a plugin with no feedback at all.
|
||||
*/
|
||||
|
||||
type Entry = (PluginItem | AvailablePlugin) & { installed?: boolean };
|
||||
type TabKey = 'installed' | 'official' | 'available';
|
||||
|
||||
export const MarketplaceOverview = ({
|
||||
installed,
|
||||
official,
|
||||
available,
|
||||
renderCard,
|
||||
onFetch,
|
||||
}: {
|
||||
installed: Entry[];
|
||||
official: Entry[];
|
||||
available: Entry[];
|
||||
renderCard: (entry: Entry) => ReactNode;
|
||||
onFetch: (url: string) => Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }>;
|
||||
}) => {
|
||||
// Opens on what you HAVE. A store that opens on the catalogue is selling; this is mostly a place
|
||||
// people come to check on something they already installed.
|
||||
const [tab, setTab] = useState<TabKey>('installed');
|
||||
|
||||
const tabs: { key: TabKey; label: string; icon: typeof Check; items: Entry[] }[] = [
|
||||
{ key: 'installed', label: 'Installed', icon: Check, items: installed },
|
||||
{ key: 'official', label: 'Official', icon: Store, items: official },
|
||||
{ key: 'available', label: 'Available', icon: Download, items: available },
|
||||
];
|
||||
const current = tabs.find((t) => t.key === tab) ?? tabs[0]!;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* One ruled row, same border as the nav beside it, so the two panels read as one screen. */}
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-3">
|
||||
{tabs.map(({ key, label, icon: Icon, items }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`flex items-center gap-1.5 border-b-2 px-3 py-2.5 text-sm transition-colors ${
|
||||
tab === key ? 'border-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
{label}
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{items.length}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Only on the tab it affects. Elsewhere it would be an action whose result appears somewhere
|
||||
you cannot see, which is the mistake this whole section is correcting. */}
|
||||
{tab === 'available' && (
|
||||
<div className="ml-auto flex min-w-0 flex-1 justify-end py-1.5 pl-3">
|
||||
{/* The tab FOLLOWS the result. A plugin whose directory was removed but whose install row
|
||||
survived comes back already installed, so "click it to install" would be pointing at a
|
||||
tab it is not in — which is exactly what happened the first time this shipped. */}
|
||||
<InstallFromUrl
|
||||
onFetch={async (url) => {
|
||||
const result = await onFetch(url);
|
||||
if (result.ok && result.installed) setTab('installed');
|
||||
return result;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
{current.items.length === 0 ? (
|
||||
<Empty tab={current.key} />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{current.items.map((entry) => renderCard(entry))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** Says what the tab is FOR, not just that it is empty — an empty tab is otherwise a dead end. */
|
||||
const Empty = ({ tab }: { tab: TabKey }) => {
|
||||
const text =
|
||||
tab === 'installed'
|
||||
? 'Nothing installed yet. Official has the catalogue.'
|
||||
: tab === 'official'
|
||||
? 'The marketplace catalogue is empty or unreachable.'
|
||||
: 'Nothing waiting to be installed. Add one from a Git repository above.';
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<GitBranch className="size-5 opacity-30" />
|
||||
<p className="text-sm text-muted-foreground">{text}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -177,7 +177,7 @@ const ActionLog = ({
|
||||
|
||||
/** Artwork if the catalogue has a URL, else the plugin's own lucide glyph. */
|
||||
const AvailableGlyph = ({ plugin }: { plugin: AvailablePlugin }) => {
|
||||
if (plugin.image) return <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 object-contain" />;
|
||||
if (plugin.image) return <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 rounded-xl object-contain" />;
|
||||
const Glyph = resolveIcon(plugin.icon ?? 'Box');
|
||||
return <Glyph className="h-9 w-9 shrink-0" style={{ color: plugin.color }} />;
|
||||
};
|
||||
@@ -431,7 +431,9 @@ export const PluginDetail = () => {
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{plugin.image ? <img src={plugin.image} alt="" className="h-10 w-10 shrink-0 object-contain" /> : null}
|
||||
{plugin.image ? (
|
||||
<img src={plugin.image} alt="" className="h-10 w-10 shrink-0 rounded-xl object-contain" />
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">{plugin.label}</h2>
|
||||
<p className="mt-0.5 text-sm text-duck-dark/60">{plugin.summary}</p>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Link, useSearchParams } from 'react-router';
|
||||
import { Check, Download } from 'lucide-react';
|
||||
import { usePlugins, type AvailablePlugin, type PluginItem } from './usePlugins';
|
||||
import { resolveIcon } from '../../utils/resolve-icon';
|
||||
import { MarketplaceOverview } from './MarketplaceOverview';
|
||||
|
||||
/**
|
||||
* The store: cards in a grid, filtered by the tag on the left.
|
||||
@@ -80,11 +81,32 @@ const Card = ({ entry, search }: { entry: Entry; search: string }) => {
|
||||
};
|
||||
|
||||
export const PluginGrid = () => {
|
||||
const { plugins, available, isLoading } = usePlugins();
|
||||
const { plugins, available, isLoading, fetchFromUrl } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const tag = params.get('tag');
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
|
||||
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||
|
||||
/*
|
||||
No tag means the Marketplace section — the state of this machine, in three tabs.
|
||||
|
||||
The tag grid below is unchanged and still what a filter shows. Two views rather than one because
|
||||
they answer different questions: "what have I got" wants installed-versus-not, and "what is there
|
||||
for my calendar" wants everything of that kind together regardless of state.
|
||||
*/
|
||||
if (!tag) {
|
||||
const installed = plugins.filter((p) => p.installed);
|
||||
const onDisk = plugins.filter((p) => !p.installed);
|
||||
return (
|
||||
<MarketplaceOverview
|
||||
installed={installed}
|
||||
official={available}
|
||||
available={onDisk}
|
||||
onFetch={fetchFromUrl}
|
||||
renderCard={(entry) => <Card key={entry.appName} entry={entry} search={params.toString()} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Installed first, then the rest. Both are the same thing to a person browsing — a plugin — and the
|
||||
// ones they already have are the ones they are most likely looking for.
|
||||
@@ -106,7 +128,9 @@ export const PluginGrid = () => {
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
<div className="mb-3 text-xs text-duck-dark/40">
|
||||
{/* No git field here any more: it lives in the Marketplace section's Available tab, which is where
|
||||
what it adds actually shows up. */}
|
||||
<div className="mb-3 text-xs text-muted-foreground">
|
||||
{shown.length} plugin{shown.length === 1 ? '' : 's'}
|
||||
{tag && tag !== '__untagged' ? ` in ${tag}` : ''}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Puzzle, AlertTriangle, Database, Package } from 'lucide-react';
|
||||
import { Puzzle, AlertTriangle, Database, Package, Store } from 'lucide-react';
|
||||
import { usePlugins, type OrphanTable } from './usePlugins';
|
||||
import { resolveIcon } from '../../utils/resolve-icon';
|
||||
|
||||
@@ -32,14 +32,31 @@ const TAG_ICONS: Record<string, string> = {
|
||||
Fun: 'Gamepad2',
|
||||
};
|
||||
|
||||
const ROW = 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm transition-colors w-full text-left';
|
||||
/*
|
||||
The house section-nav row, taken from `fofos-admin/web/NavPanel.tsx`.
|
||||
|
||||
Three things carry the look, and each is doing a job:
|
||||
|
||||
`border-b border-border` — the SEPARATION. Gapped pills read as a floating list where the boundary
|
||||
between one item and the next is guesswork; a ruled edge says where a row ends.
|
||||
|
||||
The label is NOT muted. It was `text-duck-dark/70`, so every row whispered and the eye had nothing to
|
||||
land on. The muting moves to the line BELOW it, where a count is genuinely secondary.
|
||||
|
||||
Active is `bg-muted` — a filled row rather than a tinted label. Colour on the text was carrying both
|
||||
"this is a link" and "this one is selected", which is why nothing looked selected.
|
||||
*/
|
||||
const rowClass = (active: boolean) =>
|
||||
`flex w-full items-start gap-2.5 border-b border-border px-3 py-2.5 text-left transition-colors ${
|
||||
active ? 'bg-muted' : 'hover:bg-muted/50'
|
||||
}`;
|
||||
|
||||
export const TagNav = () => {
|
||||
const { plugins, available, broken, orphans, isLoading, updateAll, updatingAll } = usePlugins();
|
||||
const [params] = useSearchParams();
|
||||
const active = params.get('tag');
|
||||
|
||||
if (isLoading) return <div className="p-4 text-sm text-duck-dark/50">Loading…</div>;
|
||||
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading…</div>;
|
||||
|
||||
// Counted over everything the grid can show — installed and available together — so a number here
|
||||
// always matches what clicking it produces.
|
||||
@@ -55,6 +72,25 @@ export const TagNav = () => {
|
||||
);
|
||||
|
||||
const untagged = all.filter((p) => !p.tags?.length).length;
|
||||
// On disk and not installed — a plugin fetched from a git URL lands here, which is why it is counted
|
||||
// beside the catalogue rather than folded into it.
|
||||
const notInstalled = plugins.filter((p) => !p.installed).length;
|
||||
|
||||
/**
|
||||
* The Marketplace section — a view, not a filter.
|
||||
*
|
||||
* `?tag=` narrows the same grid; this replaces it, so it clears the tag rather than setting one. It is
|
||||
* also the DEFAULT: arriving with neither parameter lands here, because "what do I have, and what can
|
||||
* I get" is the question somebody opening a store is asking, and the tag list is how you narrow it
|
||||
* afterwards.
|
||||
*/
|
||||
const overviewLink = () => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete('tag');
|
||||
next.delete('selected');
|
||||
return { search: next.toString() ? `?${next}` : '' };
|
||||
};
|
||||
const onOverview = !active;
|
||||
|
||||
const link = (tag: string | null) => {
|
||||
const next = new URLSearchParams(params);
|
||||
@@ -65,61 +101,70 @@ export const TagNav = () => {
|
||||
return { search: `?${next}` };
|
||||
};
|
||||
|
||||
/** One row: icon, a label that is actually legible, and what it holds underneath. */
|
||||
const row = (
|
||||
key: string,
|
||||
to: ReturnType<typeof link>,
|
||||
Icon: typeof Package,
|
||||
label: string,
|
||||
n: number,
|
||||
on: boolean,
|
||||
) => (
|
||||
<Link key={key} to={to} className={rowClass(on)}>
|
||||
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">{label}</span>
|
||||
{/* The count moves here from the right-hand column. It was a number floating at the edge at 60%
|
||||
opacity, which is where the eye goes last; as the row's second line it is read WITH the label
|
||||
rather than hunted for. */}
|
||||
<span className="block truncate text-xs text-muted-foreground tabular-nums">
|
||||
{n} plugin{n === 1 ? '' : 's'}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav className="flex h-full flex-col overflow-auto">
|
||||
<div className="flex flex-col gap-0.5 p-2">
|
||||
<Link
|
||||
to={link(null)}
|
||||
className={`${ROW} ${!active ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Package className="size-4 shrink-0" />
|
||||
<span className="flex-1">All</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{all.length}</span>
|
||||
<div className="flex flex-col">
|
||||
{/* Above the tags and separated from them: it answers a different question. The tags say what a
|
||||
plugin is FOR; this says what state this machine is in. */}
|
||||
<Link to={overviewLink()} className={`${rowClass(onOverview)} border-b-2`}>
|
||||
<Store className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm">Marketplace</span>
|
||||
<span className="block truncate text-xs text-muted-foreground tabular-nums">
|
||||
{plugins.filter((p) => p.installed).length} installed · {notInstalled + available.length} available
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{ordered.map(([tag, n]) => {
|
||||
const Icon = resolveIcon(TAG_ICONS[tag] ?? 'Puzzle');
|
||||
return (
|
||||
<Link
|
||||
key={tag}
|
||||
to={link(tag)}
|
||||
className={`${ROW} ${active === tag ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/70 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{tag}</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{n}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{row('__all', link(null), Package, 'All', all.length, false)}
|
||||
{ordered.map(([tag, n]) =>
|
||||
row(tag, link(tag), resolveIcon(TAG_ICONS[tag] ?? 'Puzzle'), tag, n, active === tag),
|
||||
)}
|
||||
|
||||
{/* Shown only when there are any. A plugin with no tags is reachable from All but from no filter,
|
||||
and that is worth being able to SEE rather than discovering by its absence. */}
|
||||
{untagged > 0 && (
|
||||
<Link
|
||||
to={link('__untagged')}
|
||||
className={`${ROW} ${active === '__untagged' ? 'bg-duck-teal/10 font-medium text-duck-teal' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<Puzzle className="size-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Untagged</span>
|
||||
<span className="text-xs tabular-nums opacity-60">{untagged}</span>
|
||||
</Link>
|
||||
)}
|
||||
{untagged > 0 && row('__untagged', link('__untagged'), Puzzle, 'Untagged', untagged, active === '__untagged')}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-1 border-t border-duck-dark/5 p-2 text-xs">
|
||||
{/* Below the rows, and separated by the same border they use — these are facts about the store
|
||||
rather than filters you can click, so they must not look like another section. */}
|
||||
<div className="mt-auto flex flex-col gap-1 border-t border-border p-3 text-xs">
|
||||
{/* Update-all belongs here rather than in a plugin's detail: it is the one action about all of
|
||||
them at once. Hidden when nothing is installed, where it would read as a broken button. */}
|
||||
{plugins.length > 0 && (
|
||||
<button
|
||||
onClick={() => updateAll()}
|
||||
disabled={updatingAll}
|
||||
className="rounded-lg px-3 py-2 text-left text-duck-dark/60 hover:bg-duck-dark/5 disabled:opacity-50"
|
||||
className="rounded-md px-2 py-1.5 text-left transition-colors hover:bg-muted/50 disabled:opacity-50"
|
||||
>
|
||||
{updatingAll ? 'Updating…' : 'Update all'}
|
||||
</button>
|
||||
)}
|
||||
{broken.length > 0 && (
|
||||
<div className="flex items-start gap-2 px-3 py-1.5 text-busy-ink">
|
||||
<div className="flex items-start gap-2 px-2 py-1.5 text-busy-ink">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{broken.length} broken plugin{broken.length === 1 ? '' : 's'}
|
||||
@@ -127,7 +172,7 @@ export const TagNav = () => {
|
||||
</div>
|
||||
)}
|
||||
{orphans.length > 0 && (
|
||||
<div className="flex items-start gap-2 px-3 py-1.5 text-duck-dark/50">
|
||||
<div className="flex items-start gap-2 px-2 py-1.5 text-muted-foreground">
|
||||
<Database className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{orphans.length} orphaned table{orphans.length === 1 ? '' : 's'} from removed plugins
|
||||
|
||||
@@ -204,23 +204,31 @@ export function usePlugins() {
|
||||
* The frame parser is deliberately small — split on blank lines, read `event:` and `data:`. It only has
|
||||
* to understand what our own endpoint emits.
|
||||
*/
|
||||
const run = useCallback(
|
||||
async (verb: PluginVerb, appName: string) => {
|
||||
/**
|
||||
* The frame reader, which does not care where the stream came from.
|
||||
*
|
||||
* Split out when "Install from Git repository" arrived: it posts to a different path with a body, and
|
||||
* everything after the first byte is identical — same step and log events, same `done`. Two copies of
|
||||
* this parser would be two places to get an incomplete frame wrong.
|
||||
*/
|
||||
const stream = useCallback(
|
||||
async (path: string, label: PluginVerb, appName: string, body?: unknown) => {
|
||||
abort.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abort.current = controller;
|
||||
|
||||
setSteps([]);
|
||||
setResult(null);
|
||||
setRunning(verb);
|
||||
setRunning(label);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/${appName}/${verb}/stream`, {
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
headers: getHeaders(),
|
||||
headers: body ? { ...getHeaders(), 'content-type': 'application/json' } : getHeaders(),
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.body) throw new Error(`${verb} failed: no response body`);
|
||||
if (!res.body) throw new Error(`${label} failed: no response body`);
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
@@ -265,6 +273,47 @@ export function usePlugins() {
|
||||
[invalidate],
|
||||
);
|
||||
|
||||
const run = useCallback(
|
||||
(verb: PluginVerb, appName: string) => stream(`/api/plugins/${appName}/${verb}/stream`, verb, appName),
|
||||
[stream],
|
||||
);
|
||||
|
||||
/**
|
||||
* Clone a plugin from its repository so it appears as Available. It does NOT install it.
|
||||
*
|
||||
* That distinction is the whole design. Installing from here ran to completion with nothing on screen,
|
||||
* because the progress log lives in the detail panel and this button is on the grid. Fetching puts a
|
||||
* card in the grid instead, and the card already has an Install button with the streamed progress
|
||||
* beside it — so there is one install flow rather than two, and the one that exists is the one people
|
||||
* have already used.
|
||||
*/
|
||||
const fetchFromUrl = useCallback(
|
||||
async (url: string): Promise<{ ok: boolean; appName?: string; installed?: boolean; error?: string }> => {
|
||||
const res = await fetch('/api/plugins/fetch-from-url', {
|
||||
method: 'POST',
|
||||
headers: { ...getHeaders(), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
appName?: string;
|
||||
installed?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/*
|
||||
AWAITED, so the caller resolves after the list has actually been refetched.
|
||||
`invalidateQueries` is fire-and-forget: without awaiting it, the confirmation appeared while the
|
||||
grid was still showing the previous answer, and the new plugin turned up a moment later — which
|
||||
reads as "nothing happened" and sends somebody looking for a refresh button.
|
||||
*/
|
||||
await queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
|
||||
// A clone that failed discovery still left something on disk worth showing, so this runs either way.
|
||||
invalidate();
|
||||
return { ok: res.ok, appName: body.appName, installed: body.installed, error: body.error };
|
||||
},
|
||||
[invalidate, queryClient],
|
||||
);
|
||||
|
||||
// Update-all is a PLAIN POST, not the streaming door the single verbs use. Twelve interleaved step
|
||||
// streams is not a readable log, and the useful answer here is the summary: how many moved, and which
|
||||
// refused. A dirty checkout is the expected refusal — every plugin directory on a developer's machine
|
||||
@@ -301,6 +350,7 @@ export function usePlugins() {
|
||||
/** Which verb is in flight, or null. */
|
||||
running,
|
||||
run,
|
||||
fetchFromUrl,
|
||||
updateAll,
|
||||
updatingAll,
|
||||
updateAllResult,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -198,6 +198,13 @@ export type DirEntry = {
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
/**
|
||||
* Set by `ls` when `/thumb` can draw this file, and which kind it is.
|
||||
*
|
||||
* The server decides, so there is one list of extensions instead of one per side. Absent on an older
|
||||
* response, which simply means no thumbnail — the file icon, exactly as before.
|
||||
*/
|
||||
thumb?: 'image' | 'video' | null;
|
||||
};
|
||||
|
||||
type ListDirResponse = {
|
||||
|
||||
@@ -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)
|
||||
@@ -139,3 +141,7 @@ export type { StoreItem as AppStoreItem } from './apps/AppStore';
|
||||
// docs/localization.md's open question and is not answered by this.
|
||||
export { panelName } from './locales/panelName';
|
||||
export { useTranslation } from 'i18n';
|
||||
|
||||
// Impersonation: the banner the owner cannot miss, and the token swap behind it. See impersonation.ts.
|
||||
export { ImpersonationBanner } from './apps/Impersonation/ImpersonationBanner';
|
||||
export { startImpersonation, endImpersonation, isImpersonating } from './apps/Impersonation/impersonation';
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Er sieht sich zunächst an, worauf Sie ihn hingewiesen haben, und fragt Sie dann, worauf er achten soll.",
|
||||
"startBlocked": "{{missing}} — dann startet dies ihn.",
|
||||
"confirmDeletingNamed": "Löschen von {{name}} bestätigen"
|
||||
"confirmDeletingNamed": "Löschen von {{name}} bestätigen",
|
||||
"fromPlugins": "Von Plugins",
|
||||
"available": "Verfügbar",
|
||||
"configureAndCreate": "Einrichten und erstellen",
|
||||
"notCreatedYet": "Noch nicht erstellt — richten Sie es ein, um zu starten."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Neuer Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "It will look at what you pointed it to, then ask you what to watch for.",
|
||||
"startBlocked": "{{missing}} — then this starts it.",
|
||||
"confirmDeletingNamed": "Confirm deleting {{name}}"
|
||||
"confirmDeletingNamed": "Confirm deleting {{name}}",
|
||||
"fromPlugins": "From plugins",
|
||||
"available": "Available",
|
||||
"configureAndCreate": "Configure and create",
|
||||
"notCreatedYet": "Not created yet — configure it to start."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "New Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Mirará aquello que le has señalado y luego te preguntará qué debe vigilar.",
|
||||
"startBlocked": "{{missing}} — y entonces esto lo inicia.",
|
||||
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmar la eliminación de {{name}}",
|
||||
"fromPlugins": "De plugins",
|
||||
"available": "Disponible",
|
||||
"configureAndCreate": "Configurar y crear",
|
||||
"notCreatedYet": "Aún no creado — configúralo para empezar."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Nuevo chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Il va regarder ce vers quoi vous l'avez orienté, puis vous demander ce qu'il doit surveiller.",
|
||||
"startBlocked": "{{missing}} — ensuite ceci le démarre.",
|
||||
"confirmDeletingNamed": "Confirmer la suppression de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmer la suppression de {{name}}",
|
||||
"fromPlugins": "Depuis les plugins",
|
||||
"available": "Disponible",
|
||||
"configureAndCreate": "Configurer et créer",
|
||||
"notCreatedYet": "Pas encore créé — configurez-le pour commencer."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Nouveau Chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Vai olhar para aquilo que lhe indicou e depois perguntar-lhe o que deve vigiar.",
|
||||
"startBlocked": "{{missing}} — depois é isto que o inicia.",
|
||||
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}"
|
||||
"confirmDeletingNamed": "Confirmar a eliminação de {{name}}",
|
||||
"fromPlugins": "De plugins",
|
||||
"available": "Disponível",
|
||||
"configureAndCreate": "Configurar e criar",
|
||||
"notCreatedYet": "Ainda não criado — configure para começar."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Novo chat",
|
||||
|
||||
@@ -257,7 +257,11 @@
|
||||
},
|
||||
"startHint": "Den tittar på det du pekade den mot, och frågar dig sedan vad den ska bevaka.",
|
||||
"startBlocked": "{{missing}} — sedan startar detta den.",
|
||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}"
|
||||
"confirmDeletingNamed": "Bekräfta borttagning av {{name}}",
|
||||
"fromPlugins": "Från plugin",
|
||||
"available": "Tillgänglig",
|
||||
"configureAndCreate": "Konfigurera och skapa",
|
||||
"notCreatedYet": "Inte skapad än — konfigurera för att starta."
|
||||
},
|
||||
"chatHistory": {
|
||||
"newChat": "Ny chatt",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
id: plugin-ecosystem/no-install-from-git-url
|
||||
title: "No install-from-git-URL"
|
||||
theme: plugin-ecosystem
|
||||
status: open
|
||||
status: done
|
||||
created: 2026-08-18
|
||||
date_source: section
|
||||
order: 3
|
||||
@@ -11,3 +11,18 @@ order: 3
|
||||
**No install-from-git-URL.** The catalogue is the only route for a remote plugin, so the
|
||||
"build your own, give it to a friend" path has no affordance. Small, and needed before the
|
||||
unofficial channel is real.
|
||||
|
||||
**Done 2026-08-28.** `POST /api/plugins/install-from-url`, owner-only, taking `{ url }` and optionally
|
||||
`{ appName }`.
|
||||
|
||||
Smaller than this note assumed: `fetchPlugin` already cloned, validated the URL, refused to overwrite an
|
||||
existing directory, checked for a `manifest.ts` and cleaned up a failed clone. One line tied it to the
|
||||
catalogue — where the URL came from. So the change is a source parameter threaded through, plus a route.
|
||||
|
||||
The name comes from the repository, because `appName` IS the directory name and is never declared in a
|
||||
manifest. That is checked against `^[a-z0-9]+(-[a-z0-9]+)*$` BEFORE cloning: it becomes the route
|
||||
segment, the table prefix and the pm2 process name, and a half-clone under an unusable name reads as a
|
||||
broken plugin rather than an absent one.
|
||||
|
||||
The first real user is `fofos/fofos-admin` — one website's admin panel, which should never be in a
|
||||
registry. Verified end to end against it and then purged, since this is not that website's machine.
|
||||
|
||||
Reference in New Issue
Block a user