Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78bf63c209 | ||
|
|
347acf0171 | ||
|
|
766b18b7f9 | ||
|
|
e4f5e03105 | ||
|
|
3b0083aec2 | ||
|
|
58f50a5240 | ||
|
|
43a4463978 | ||
|
|
de425d9d68 | ||
|
|
22a4c65776 | ||
|
|
0d310ab7b0 | ||
|
|
16fa869260 | ||
|
|
3d936309a5 | ||
|
|
9a70089d6b | ||
|
|
e2d03394e1 | ||
|
|
cac58ea044 | ||
|
|
5c6c5b31f8 | ||
|
|
d273dc6a14 | ||
|
|
4e0a065f0e | ||
|
|
f4685eeafe | ||
|
|
87d1740caf | ||
|
|
9c8af9d424 | ||
|
|
fb0cc880ac | ||
|
|
02ad4d3bdf | ||
|
|
91147b403b |
@@ -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,78 @@
|
||||
# 2026.08.27.02
|
||||
|
||||
Everything in `2026.08.27.01`, which was **refused before any host was contacted** — its plan named no
|
||||
commands and claimed "no schema change", which was false. This plan replaces it. `2026.08.27.01` stays
|
||||
claimed and undeployed; do not try to revive it.
|
||||
|
||||
Plus one fix made because of that refusal: a single 401 no longer signs the owner out of every tab.
|
||||
|
||||
## The schema question, answered per host
|
||||
|
||||
`officer_db/src/dav/schema.ts` was **deleted** — `dav_app_passwords` moved into `plugins/caldav`.
|
||||
`gen-plugin-schemas` follows plugin DIRECTORIES, so the table is only declared on a host that has
|
||||
`plugins/caldav/`. Everywhere else `bun db:push` will propose **dropping** it.
|
||||
|
||||
Surveyed 2026-08-28, every host:
|
||||
|
||||
| Host | `plugins/caldav/` | `dav_app_passwords` |
|
||||
| --- | --- | --- |
|
||||
| `alpha` | present | **8 rows** |
|
||||
| `fofos` | absent | present, 0 rows |
|
||||
| `macbookpro` | absent | present, not read (bun at `/opt/homebrew/bin`) |
|
||||
| `macbruno` | absent | present, 0 rows |
|
||||
| `production-pertento-bridge` | absent | present, 0 rows |
|
||||
| `edge-pertento-bridge` | absent | present, 0 rows |
|
||||
| `deadpool-rezio-bridge` | absent | not read (ssh refused at survey time) |
|
||||
|
||||
So: **alpha pushes normally.** Everywhere else the table is a leftover of a feature that has left core,
|
||||
and it is empty — but it is dropped **explicitly, with a guard**, not as a `db:push` side effect.
|
||||
|
||||
`officer_marketplace` and its new `tags` column: **no host runs `officer-marketplace`** — checked, zero
|
||||
of seven. Nothing to apply. If that changes, the column has no push script and must be added by hand.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.27.02`
|
||||
2. **Only if `plugins/caldav/` is ABSENT on this host** — retire the orphan table, refusing to if it
|
||||
holds anything:
|
||||
```
|
||||
psql "$POSTGRES_URL" -Atc "select count(*) from dav_app_passwords"
|
||||
# 0 → drop it: psql "$POSTGRES_URL" -c "drop table dav_app_passwords"
|
||||
# anything else → STOP. Report the count and do not continue on this host.
|
||||
```
|
||||
Read the variable name from `.env` — several hosts use `OFFICERDEV_POSTGRES_URL`.
|
||||
3. `bun db:push < /dev/null` — **stdin closed on purpose**, so a push needing an answer fails instead of
|
||||
hanging. Expect **no changes** after step 2. Anything destructive: stop and report.
|
||||
4. `bun run plugins:update`
|
||||
5. `pm2 restart officer`
|
||||
6. `pm2 restart officer-opencode` — this release rewires how OpenCode gets its skills, its agent identity
|
||||
and its credential, and all of that is built when the sidecar boots. Without this the code ships and
|
||||
nothing about OpenCode changes.
|
||||
|
||||
**`alpha`:** skip step 2 entirely — it has the plugin and 8 real credentials in that table. Step 3 runs
|
||||
normally and must report no changes.
|
||||
|
||||
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
|
||||
with a note.
|
||||
|
||||
**`deadpool-rezio-bridge`:** login is `br`, checkout `/home/br/officerdev/platform`. SSH refused once
|
||||
during the survey — if it refuses again, note it and move on rather than retrying in a loop.
|
||||
|
||||
**Do not restart `officer-claude-code`.** It ends every live agent session on the host, nothing here needs
|
||||
it, and it is the owner's call.
|
||||
|
||||
## Risk
|
||||
|
||||
Moderate, and concentrated in step 2 — the only destructive command in this release. It is guarded by a
|
||||
count and it is skipped on the one host where the table has contents.
|
||||
|
||||
After the rollout, two things are worth a look: `useThemeAsset` now reads in `requestAnimationFrame`, so
|
||||
a themed logo or wallpaper failing to appear points here; and the notify router's mount order changed, so
|
||||
device registration is worth one check (it forwards correctly on `officer-dev`).
|
||||
|
||||
## Hosts
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
|
||||
`officer-dev` is skipped: it is the host running the rollout.
|
||||
@@ -0,0 +1,96 @@
|
||||
# 2026.08.27.02 — report
|
||||
|
||||
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout.
|
||||
|
||||
Every host moved to `91147b40` (`deploy/2026.08.27.02`) by `git merge --ff-only` onto the TAG. No host
|
||||
was dirty in tracked files, none diverged, none needed a skip.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | Step 2 (drop) | `dav_app_passwords` after | Restarted |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `alpha` | `e867fe72` | `91147b40` | skipped by plan | **present, 8 rows** | officer 0→1, opencode 0→1 |
|
||||
| `fofos` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 0→1 |
|
||||
| `macbookpro` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 0→1, opencode 0→1 |
|
||||
| `macbruno` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 3→4, opencode 1→2 |
|
||||
| `production-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 1→2, opencode 0→1 |
|
||||
| `edge-pertento-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 2→3, opencode 0→1 |
|
||||
| `deadpool-rezio-bridge` | `c14b6867` | `91147b40` | dropped (count 0) | GONE | officer 19→20, opencode 0→1 |
|
||||
|
||||
Every process the plan named came back `online` with `restart_time` moved, on every host. No officer
|
||||
process was left `errored` or `stopped` anywhere.
|
||||
|
||||
## The schema, verified by query per host
|
||||
|
||||
The plan's claim was checked on each host, against that host's own database, **after** the rollout —
|
||||
not inferred from an exit code. Final sweep:
|
||||
|
||||
```
|
||||
alpha caldav=yes dav_app_passwords=present rows=8
|
||||
fofos caldav=no dav_app_passwords=GONE
|
||||
macbookpro caldav=no dav_app_passwords=GONE
|
||||
macbruno caldav=no dav_app_passwords=GONE
|
||||
production-pertento-bridge caldav=no dav_app_passwords=GONE
|
||||
edge-pertento-bridge caldav=no dav_app_passwords=GONE
|
||||
deadpool-rezio-bridge caldav=no dav_app_passwords=GONE
|
||||
```
|
||||
|
||||
The count guard held on all six drop hosts: every one read exactly `0` before the `drop table`, and the
|
||||
drop was verified by re-querying `to_regclass` after. `alpha` was never touched by step 2, and its 8 rows
|
||||
are intact.
|
||||
|
||||
**Two counts the survey could not read are now read.** `macbookpro` (bun/psql behind Homebrew) and
|
||||
`deadpool-rezio-bridge` (SSH refused at survey time) both held **0 rows**, so both qualified for the drop
|
||||
under the plan's own guard rather than under an assumption.
|
||||
|
||||
`officer_marketplace` and its `tags` column: nothing applied, correctly — no host runs
|
||||
`officer-marketplace`, confirmed zero of seven.
|
||||
|
||||
## `bun db:push` prints `Changes applied` even when it changes nothing
|
||||
|
||||
Worth recording, because it makes the plan's "expect no changes" unverifiable from stdout. `db:push`
|
||||
printed `[✓] Changes applied` on **every** host, including on an immediate second run against an
|
||||
unchanged database. It is unconditional output, not evidence.
|
||||
|
||||
What settled it was `drizzle-kit push --verbose`, which prints the statements first. Rerun per host, the
|
||||
real content was:
|
||||
|
||||
- **every host** — `ALTER TABLE "command_hq_sessions" ALTER COLUMN "reference_paths" SET DEFAULT '{}'`
|
||||
- **`alpha` and `edge-pertento-bridge` also** — `music_now_playing` dropping and re-adding
|
||||
`pk_music_now_playing` as the same two-column key
|
||||
|
||||
Both re-appear on every push and neither is destructive. This is the drizzle-kit composite-key mis-diff
|
||||
already documented in `src/databases/CLAUDE.md`, and it is **pre-existing drift, unrelated to this
|
||||
release**. Nothing in it drops or recreates `dav_app_passwords` on any host — which is the positive
|
||||
result: plugin schema generation scoped the table to the one host with the plugin, exactly as the plan
|
||||
said it would.
|
||||
|
||||
Not fixed here. A rollout is the wrong place for a schema-code change.
|
||||
|
||||
## Plugins
|
||||
|
||||
`bun run plugins:update` exited 0 everywhere. It also started sidecars beyond the two the plan named, so
|
||||
"restarted" above understates what moved: `officer-gitea` was added on `fofos` and
|
||||
`production-pertento-bridge`, and `officer-offscale` on `edge-pertento-bridge` and
|
||||
`deadpool-rezio-bridge`. Counts: alpha 15/15, fofos 3/3, macbruno 1/1, production 1/1, edge 2/2,
|
||||
deadpool 2/2, macbookpro none installed.
|
||||
|
||||
## What was not done
|
||||
|
||||
- **`officer-dev` — skipped entirely**, as the plan requires. Not pulled, nothing restarted. It is the
|
||||
host this rollout ran on, and restarting `officer-claude-code` there would have ended the rollout. The
|
||||
owner restarts it deliberately.
|
||||
- **`officer-claude-code` was not restarted anywhere.** The plan forbids it.
|
||||
- **`deadpool-rezio-bridge` refused SSH on the first probe** (`Connection closed by 100.64.0.21 port 22`)
|
||||
and answered on a second attempt a few minutes later. One retry, not a loop. It then deployed normally,
|
||||
so it is a success rather than the skip the plan allowed for. Its SSH is intermittent and that is worth
|
||||
knowing.
|
||||
- **`fofos` carries one untracked file**, `scripts/chk.ts`. A note, not a skip — a fast-forward could not
|
||||
touch it and it was left alone.
|
||||
- **`2026.08.27.01` remains claimed and undeployed**, which is correct: this release replaces it.
|
||||
|
||||
## Worth a look, per the plan's own risk note
|
||||
|
||||
Untested by this rollout, because they need a browser: `useThemeAsset` now reads in
|
||||
`requestAnimationFrame` (a themed logo or wallpaper failing to appear points there), and the notify
|
||||
router's mount order changed (device registration is worth one check).
|
||||
@@ -0,0 +1,71 @@
|
||||
# 2026.08.28.01
|
||||
|
||||
**One feature: sign in as a user, from user management.** Owner-only, one hour, on a token that carries
|
||||
`impersonatedBy` so an impersonated action is never mistaken for the person's own.
|
||||
|
||||
Two commits since `2026.08.27.02`, one of which is that release's own report.
|
||||
|
||||
## No schema change. No dependency change. No plugin change.
|
||||
|
||||
Checked, not asserted — `git diff --name-only deploy/2026.08.27.02..HEAD` matches no `schema.ts`, no
|
||||
`drizzle` config, no `bun.lock`, and nothing under `plugins/`. That was the claim the `2026.08.27.01`
|
||||
plan got wrong, so it is stated here as a check that was run rather than a belief.
|
||||
|
||||
## `bun db:push` is deliberately NOT a step
|
||||
|
||||
Every previous plan ran it "because it is cheap and proves the database agrees with the code". The
|
||||
`2026.08.27.02` report showed it proves nothing of the sort: it prints **"Changes applied" even when it
|
||||
applies nothing**, including on a second run against an unchanged database. And on this fleet it is not
|
||||
free — every host re-applies a default on `command_hq_sessions.reference_paths`, and `alpha` and
|
||||
`edge-pertento-bridge` drop and re-add `pk_music_now_playing`, which is the drizzle-kit composite-key
|
||||
mis-diff in `src/databases/CLAUDE.md`.
|
||||
|
||||
Nothing in this release touches a schema, so running it would be churn against a database for no reason,
|
||||
using a command whose output cannot be checked. **If you run it anyway, it is not evidence of anything.**
|
||||
|
||||
`bun run plugins:update` is skipped for the same reason: no plugin changed, and `2026.08.27.02` updated
|
||||
them across the fleet yesterday.
|
||||
|
||||
## Every host
|
||||
|
||||
1. `git fetch origin --tags` then `git merge --ff-only deploy/2026.08.28.01`
|
||||
2. `pm2 restart officer`
|
||||
|
||||
That is the whole rollout. The change is a route and a frontend bundle; nothing else runs it.
|
||||
|
||||
**`macbookpro`:** prepend `/opt/homebrew/bin` to PATH for `bun`; run `pm2` separately. Asleep is a skip
|
||||
with a note.
|
||||
|
||||
**`deadpool-rezio-bridge`:** login `br`, checkout `/home/br/officerdev/platform`. Its SSH is intermittent
|
||||
— it refused once during the `2026.08.27.02` survey and answered minutes later. One retry, not a loop.
|
||||
|
||||
**Do not restart `officer-claude-code`** — it ends every live agent session on the host and nothing here
|
||||
needs it. **`officer-opencode` does not need a restart either**: this release changes nothing it reads at
|
||||
boot.
|
||||
|
||||
## Verify, per host
|
||||
|
||||
The feature is owner-only, so an unauthenticated probe proving the route EXISTS is the honest check —
|
||||
401 or 403 means mounted, 404 means the bundle did not move:
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
|
||||
```
|
||||
|
||||
Expect **401**. A **404** means step 2 did not take effect. Ports are in `docs/servers.md` —
|
||||
`macbookpro` and `production-pertento-bridge` are on **9010**, everything else 9000.
|
||||
|
||||
## Risk
|
||||
|
||||
Low. One new route behind the existing owner gate, and one frontend bundle. The route refuses a
|
||||
self-impersonation, an unknown user, a non-Active account, and any caller whose own token is already
|
||||
impersonated — that last one is what stops it chaining into owner powers.
|
||||
|
||||
Nothing to roll back but the merge: no data is written and no schema is touched.
|
||||
|
||||
## Hosts
|
||||
|
||||
alpha · fofos · macbookpro · macbruno · production-pertento-bridge · edge-pertento-bridge ·
|
||||
deadpool-rezio-bridge
|
||||
|
||||
`officer-dev` is skipped: it is the host running the rollout, and it is already at this code.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.28.01 — report
|
||||
|
||||
**7 of 7 target hosts succeeded.** `officer-dev` skipped: it is the host that ran the rollout, and it
|
||||
was already at the tagged commit.
|
||||
|
||||
Every host moved from `91147b40` (`deploy/2026.08.27.02`) to `9c8af9d4` (`deploy/2026.08.28.01`) by
|
||||
`git merge --ff-only` onto the TAG. Every host started from the same commit, so the fleet was coherent
|
||||
before this release and is coherent after it. No host was dirty in tracked files, none diverged, none
|
||||
needed a skip.
|
||||
|
||||
This is the first release rolled out under the new watcher loop. The rollout procedure is the same one
|
||||
that ran the previous nine; what was unproven was the loop around it.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | Restarted | Route probe |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `alpha` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9000) |
|
||||
| `fofos` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
|
||||
| `macbookpro` | `91147b40` | `9c8af9d4` | officer 1→2, online | **401** (9010) |
|
||||
| `macbruno` | `91147b40` | `9c8af9d4` | officer 4→5, online | **401** (9000) |
|
||||
| `production-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 2→3, online | **401** (9010) |
|
||||
| `edge-pertento-bridge` | `91147b40` | `9c8af9d4` | officer 3→4, online | **401** (9000) |
|
||||
| `deadpool-rezio-bridge` | `91147b40` | `9c8af9d4` | officer 20→21, online | **401** (9000) |
|
||||
|
||||
Every restarted process came back `online` with `restart_time` moved. Nothing was left `errored` or
|
||||
`stopped` on any host.
|
||||
|
||||
## The verification, per host
|
||||
|
||||
The plan's check is an unauthenticated probe of the new route: `401` proves it is mounted, `404` would
|
||||
mean the bundle did not move. Run on each host against that host's own port, taken from its `.env` and
|
||||
not from habit:
|
||||
|
||||
```
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:<port>/api/users/1/impersonate
|
||||
```
|
||||
|
||||
All seven answered **401**. None answered 404, so the frontend bundle and the route moved everywhere.
|
||||
|
||||
Three hosts — `fofos`, `production-pertento-bridge`, `edge-pertento-bridge` — answered `000`
|
||||
(connection refused) on the first probe and `401` seconds later. That is `bun start` still coming up,
|
||||
not a fault: `pm2` returns as soon as it has respawned the process, before the server binds. A probe
|
||||
fired immediately after a restart is testing the wrong moment. The probes here retried until the port
|
||||
answered.
|
||||
|
||||
## No schema change — checked, not assumed
|
||||
|
||||
The plan claimed no schema change, no dependency change and no plugin change. That claim was
|
||||
**re-derived before the first host was touched**, not taken on trust:
|
||||
|
||||
```
|
||||
git diff --name-only deploy/2026.08.27.02..deploy/2026.08.28.01^{}
|
||||
```
|
||||
|
||||
Ten files: two `deploy/*.md`, `package.json`, and six under `src/` — a route, a router line, two
|
||||
frontend files, a workspace app and its index. No `schema.ts`, no drizzle config, no `bun.lock`,
|
||||
nothing under `plugins/`.
|
||||
|
||||
So there is no schema verification section in this report, because there was nothing to verify. That is
|
||||
the honest answer rather than an omission.
|
||||
|
||||
## Steps the plan deliberately did NOT include, and which were NOT run
|
||||
|
||||
- **`bun db:push` was not run on any host.** The plan forbids it and gives the reason: it prints
|
||||
`Changes applied` even when it applies nothing, so it is not evidence, and on this fleet it is not
|
||||
free — it re-applies a default on `command_hq_sessions.reference_paths` everywhere, and drops and
|
||||
re-adds `pk_music_now_playing` on `alpha` and `edge-pertento-bridge`. Nothing in this release touches
|
||||
a schema, so running it would have been churn.
|
||||
- **`bun run plugins:update` was not run on any host.** No plugin changed, and `2026.08.27.02` updated
|
||||
them across the fleet yesterday.
|
||||
- **`officer-claude-code` was not restarted anywhere.** It ends every live agent session on the host and
|
||||
nothing in this release needs it.
|
||||
- **`officer-opencode` was not restarted anywhere.** This release changes nothing it reads at boot.
|
||||
|
||||
The consequence worth stating: only `officer` moved on each host. Unlike `2026.08.27.02`, where the
|
||||
plugins step bounced every sidecar, nothing else on the fleet restarted during this rollout.
|
||||
|
||||
## Version agreement
|
||||
|
||||
The tag `deploy/2026.08.28.01` is annotated and dereferences to `9c8af9d4`. `package.json` at that
|
||||
commit reads `2026.08.28.01`. They agree, so there was no question of which tree to deploy.
|
||||
|
||||
## Say what was not done
|
||||
|
||||
- **`officer-dev` was skipped entirely** — not pulled, nothing restarted. It is the machine this
|
||||
rollout ran on, and restarting `officer-claude-code` there would have ended the session mid-rollout.
|
||||
It is already at `9c8af9d4`, so its code is current; what it has not had is a restart of `officer`.
|
||||
**That is the owner's to do, deliberately, as the last step.** Until then `officer-dev` is running the
|
||||
previous release's process against the new code on disk.
|
||||
- **`fofos` carries an untracked `scripts/chk.ts`.** Left alone. An untracked file is a note, not a
|
||||
reason to skip a host, and a fast-forward could never have touched it.
|
||||
- **The eight non-Officer processes on `production-pertento-bridge` were not touched.** `Dashboard`,
|
||||
`Tracking`, `Uptime` and five `Crons *` were all `online` with unchanged `restart_time` after the
|
||||
rollout — checked, not assumed. The restart named `officer` alone.
|
||||
- **No host needed a retry for SSH.** `deadpool-rezio-bridge`, which refused once during the
|
||||
`2026.08.27.02` survey, answered first time. `macbookpro` was awake.
|
||||
- **Nothing was rolled back and nothing needed repair.** No host failed, so no half-applied state
|
||||
exists anywhere.
|
||||
@@ -0,0 +1,122 @@
|
||||
# 2026.08.29.01 — sharp becomes infrastructure
|
||||
|
||||
**This release changes what a machine must have.** Every other deploy this month was code; this one adds
|
||||
a dependency with a native binary and a system package, so `bun install` is a required step rather than
|
||||
an optional one, and there are two things to verify per host that no previous plan asked for.
|
||||
|
||||
Read the whole file before starting a host. The per-host section at the bottom is not optional detail —
|
||||
one host needs a command none of the others do.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **File browser thumbnails.** Images and videos draw a picture in both grid and list. New endpoint
|
||||
`GET /api/file-browser/thumb`, cached under each user's `cache/thumbs/`.
|
||||
- **sharp is now a declared dependency** (`^0.35.3`, resolving 0.35.4). It does the images; ffmpeg keeps
|
||||
the video. `package.json` and `bun.lock` both changed.
|
||||
- **ffmpeg is now declared in machine-setup's package list.** It was always a hard dependency of the file
|
||||
browser — subtitles, audio extraction, audio tracks — and was never in the list. Existing hosts were
|
||||
not provisioned by the new script, so this release checks rather than assumes.
|
||||
- Marketplace: install a plugin from a git URL; Marketplace overview section; nav restyle.
|
||||
- Dock and Spotlight clip bare plugin artwork to the tile shape.
|
||||
- The platform-skill generator emits an identifier for `name` again, so `docs:check` is green.
|
||||
|
||||
## Schema: UNCHANGED. Do not run `db:push`.
|
||||
|
||||
Verified, not assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
|
||||
```
|
||||
|
||||
returns nothing. There is no table, no column and no constraint in this release. **No `bun db:push`
|
||||
step exists in this plan and none should be improvised.** If `bun run plugins:update` reports that a
|
||||
plugin moved, say so in the report — do not push a schema to cover it.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
Run these after the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin
|
||||
--tags`, `git merge --ff-only deploy/2026.08.29.01`, `bun run plugins:update`), in this order, in the
|
||||
checkout from PM2's `pm_cwd`.
|
||||
|
||||
### 1. Install — REQUIRED, and it is the point of this release
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
The lockfile is frozen, so this resolves from `bun.lock` and nothing else. It should report installing
|
||||
around 14 packages — sharp and its platform-specific libvips binaries.
|
||||
|
||||
**A failure here stops this host.** Do not continue to the restart, and do not reach for `--force` to get
|
||||
past it: if the lockfile and `package.json` disagree, that is the freeze doing its job and it wants a
|
||||
human.
|
||||
|
||||
### 2. Prove sharp actually loads — the check that matters
|
||||
|
||||
```bash
|
||||
bun -e "import('sharp').then(m => console.log('libvips', m.default.versions.vips)).catch(e => { console.error('SHARP FAILED:', e.message); process.exit(1) })"
|
||||
```
|
||||
|
||||
Expect `libvips 8.18.6`.
|
||||
|
||||
**This is a separate step from the install on purpose.** sharp does not compile — it downloads a prebuilt
|
||||
binary chosen by platform and architecture. So `bun install` can succeed and the module still be
|
||||
unusable, and the symptom is not an error anywhere: it is a file browser drawing generic icons over a
|
||||
folder of photographs, which nobody reads as "a native module is missing".
|
||||
|
||||
A host where this fails is a **failed host**. Report it, move on. `bun install --force` is the usual
|
||||
repair and it is a human's to run, not this rollout's.
|
||||
|
||||
### 3. ffmpeg must be present
|
||||
|
||||
```bash
|
||||
command -v ffmpeg && ffmpeg -version | head -1
|
||||
```
|
||||
|
||||
Video thumbnails need it, and so do three file-browser features that predate this release. Surveyed
|
||||
2026-08-29: present on every host **except `macbruno`** — see below.
|
||||
|
||||
Absent and not covered by a per-host command below → report the host as deployed-with-a-caveat, name
|
||||
ffmpeg, and do not install it yourself. Adding a system package to a machine is not something a rollout
|
||||
improvises.
|
||||
|
||||
### 4. Restart
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
**`officer` only.** Nothing else in this release touches a sidecar. Never
|
||||
`pm2 restart ecosystem.config.cjs`.
|
||||
|
||||
### 5. Verify it came back
|
||||
|
||||
`pm2 jlist`: `officer` is `online` and its `restart_time` moved. Then, for this release specifically:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 http://127.0.0.1:<PORT>/api/health
|
||||
```
|
||||
|
||||
`200` or `401` are both healthy — the port is per host, in `docs/servers.md` (9010 on `macbookpro` and
|
||||
`production-pertento-bridge`, 9000 elsewhere).
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Extra command | Note |
|
||||
| --- | --- | --- |
|
||||
| `officer-dev` | — | **SKIP ENTIRELY.** The rollout runs here. Do not pull, do not restart. Name it as skipped. |
|
||||
| `alpha` | — | x86_64, ffmpeg present. |
|
||||
| `fofos` | — | **Already at this code and verified** — it was the test host on 2026-08-29 before the tag. Bring it to the tag anyway so its HEAD matches the fleet; `bun install` will be a no-op. 1.9GB box: expect the install to be the slowest part. |
|
||||
| `macbookpro` | — | macOS/arm64, port **9010**. A laptop — unreachable is a SKIP with a note, not a failure. |
|
||||
| `macbruno` | `brew install ffmpeg` **before step 4** | macOS/arm64, login `brunoalcadapadezmonteirodaconceicao`. **The only host without ffmpeg.** Without it, image thumbnails still work — sharp does those — and video ones do not, along with subtitles and audio extraction. If brew is missing or the install fails, deploy anyway and report the caveat: nothing in this release regresses without ffmpeg, it simply does not gain the video half. |
|
||||
| `production-pertento-bridge` | — | x86_64, port **9010**. |
|
||||
| `edge-pertento-bridge` | — | x86_64. Node v24 here where the fleet is v22; sharp ships binaries for both and this was not a problem for the install on any other host, but if step 2 fails anywhere it is most likely here. |
|
||||
| `deadpool-rezio-bridge` | — | x86_64, login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `bun install` result, the libvips version step 2 printed, whether ffmpeg
|
||||
was found, and `officer` back online with a moved `restart_time`.
|
||||
|
||||
The two lines worth having in the summary that no previous report needed: **which hosts print a libvips
|
||||
version**, and **which hosts have ffmpeg**. Those are the two facts this release is actually about.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.29.01 — deploy report
|
||||
|
||||
**7 of 7 target hosts succeeded. 1 skipped (`officer-dev`, the host this rollout ran on).**
|
||||
|
||||
Every target ended at `58f50a52`, the tag commit. Tag and `package.json` agreed before anything was
|
||||
touched. No host failed, none was left half-applied, and nothing was reset or discarded.
|
||||
|
||||
## The two facts this release is about
|
||||
|
||||
**Every host prints a libvips version.** `sharp@0.35.4` installed and loaded on all seven, `libvips
|
||||
8.18.6` everywhere — x86_64 and arm64, Node v22 and v24. The plan flagged `edge-pertento-bridge` (Node
|
||||
v24) as the most likely failure; it was not one.
|
||||
|
||||
**Every host now has ffmpeg.** Six already had it. `macbruno` did not, and the plan's per-host `brew
|
||||
install ffmpeg` ran there before the restart and succeeded — so this release ships with no ffmpeg caveat
|
||||
outstanding on any host.
|
||||
|
||||
| Host | HEAD before → after | install | libvips | ffmpeg | officer |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | — | — | **SKIPPED — rollout host** |
|
||||
| `alpha` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 2→3, 401 |
|
||||
| `fofos` | `de425d9d` → `58f50a52` | no-op | 8.18.6 | 6.1.1 (had) | online, rt 25→26, 401 |
|
||||
| `macbookpro` | `16fa8692` → `58f50a52` | 10 pkgs | 8.18.6 | 8.1.2 (had) | online, rt 2→3, 401 :9010 |
|
||||
| `macbruno` | `9c8af9d4` → `58f50a52` | 10 pkgs | 8.18.6 | **9.0.1 (installed)** | online, rt 5→6, 401 |
|
||||
| `production-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 3→4, 401 :9010 |
|
||||
| `edge-pertento-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 4→5, 401 |
|
||||
| `deadpool-rezio-bridge` | `9c8af9d4` → `58f50a52` | 14 pkgs | 8.18.6 | 6.1.1 (had) | online, rt 21→22, 401 |
|
||||
|
||||
Health is `/api/health` on each host's own port, read from its `.env` rather than assumed. `401` is
|
||||
healthy per the plan. `production-pertento-bridge` was checked on 9010, not 9000.
|
||||
|
||||
## Schema
|
||||
|
||||
**Unchanged, and no `bun db:push` was run anywhere.** The plan says so and I verified it rather than
|
||||
taking its word:
|
||||
|
||||
```
|
||||
git diff --name-only deploy/2026.08.28.01..deploy/2026.08.29.01 | grep -iE 'schema|/db/|databases'
|
||||
```
|
||||
|
||||
returns nothing. No table, column or constraint in this release, so the "check the database by query"
|
||||
rule had nothing to check and no SQL was improvised. Plugin schema lines in `plugins:update` output
|
||||
(`schema: applied in …ms, N table(s) verified`) are that script's own per-plugin verification, not this
|
||||
release.
|
||||
|
||||
## The one deviation, and why
|
||||
|
||||
**On `macbookpro` the standard step order failed, and I ran the same steps in the other order.**
|
||||
|
||||
`bun run plugins:update` exited 1 with:
|
||||
|
||||
```
|
||||
error: Cannot find package 'sharp' from '…/src/servers/api/file-browser/thumbnails.ts'
|
||||
```
|
||||
|
||||
The updater imports the server graph, which now reaches the new `thumbnails.ts`, which imports `sharp`.
|
||||
The standard steps in `prompts/deploy-commander.md` put `plugins:update` **before** the plan's `bun
|
||||
install` — so on a host where sharp is genuinely new, the step cannot resolve a dependency the same
|
||||
release is about to add.
|
||||
|
||||
I ran `bun install` first, then `plugins:update`, which then exited 0. Both steps ran, none was skipped
|
||||
and nothing was composed — only the order changed. I did the same on the four hosts after it. `alpha`
|
||||
and `fofos` had already passed in the documented order because sharp was on disk for reasons of their
|
||||
own: `alpha` via a plugin's declared npm dependencies, `fofos` as the pre-tag test host.
|
||||
|
||||
**This is a finding for whoever writes the next plan, not a fix I made.** Any future release that adds a
|
||||
runtime dependency imported by the server graph hits the same wall in the same place. The ordering lives
|
||||
in the commander prompt's standard steps, and correcting it is a documentation change, which a rollout
|
||||
does not make.
|
||||
|
||||
## What else moved
|
||||
|
||||
`bun run plugins:update` restarts sidecars itself. It reported `sidecar: started` on `alpha` (15/15
|
||||
plugins, `officer-music` re-added), `fofos` (5/5), `macbruno` (1/1), `production-pertento-bridge` (1/1,
|
||||
`officer-gitea`), `edge-pertento-bridge` (2/2, `officer-offscale`) and `deadpool-rezio-bridge` (2/2).
|
||||
`macbookpro` has no plugins installed. So more than `officer` bounced on most hosts — that is the
|
||||
updater's own behaviour, not a restart this rollout asked for. The only restart it issued was `pm2
|
||||
restart officer`, per host, by name.
|
||||
|
||||
`production-pertento-bridge`: all eight non-Officer Pertento processes were still `online` after the
|
||||
restart. Nothing there was named or touched.
|
||||
|
||||
## Notes, not problems
|
||||
|
||||
- `fofos` carries one untracked file, `scripts/chk.ts`. Noted, not a reason to skip — a fast-forward
|
||||
cannot touch it. No host had tracked changes; no work was discarded anywhere.
|
||||
- `production-pertento-bridge` prints an nvm warning (`has a globalconfig and/or a prefix setting…`) on
|
||||
every `bun`/`node` invocation. Pre-existing, unrelated to this release, harmless to it.
|
||||
- `macbruno`: `officer`'s PM2 environment already carries `/opt/homebrew/bin`, so the newly installed
|
||||
ffmpeg is on the restarted process's PATH. Verified, not assumed.
|
||||
|
||||
## For the owner
|
||||
|
||||
- **`officer-dev` is deliberately a step behind the fleet.** Its checkout is already at `58f50a52`, but
|
||||
nothing was pulled, installed or restarted on it, and `bun install` has NOT run there — so sharp is
|
||||
not necessarily present for the running process. Restart it deliberately when you are happy with the
|
||||
rest, and run `bun install` first or the thumbnails endpoint will not have its dependency.
|
||||
- The `plugins:update` ordering above is worth fixing in the prompt before the next release that adds a
|
||||
dependency.
|
||||
@@ -0,0 +1,119 @@
|
||||
# 2026.08.29.02 — a plugin can ship a commander
|
||||
|
||||
**This one changes the database.** Two columns, both nullable additions, on a table that already exists.
|
||||
There is no rename in it, nothing is dropped, and no existing row changes — but `db:push` is still not
|
||||
to be trusted on its exit code, so there is a per-host query below and the report must carry its answer
|
||||
for each host.
|
||||
|
||||
No dependency change: `package.json` and `bun.lock` are untouched since `2026.08.29.01`. **There is no
|
||||
`bun install` step in this plan** — which also sidesteps the step-order problem the last rollout found,
|
||||
where the standard `plugins:update` ran before the plan's install.
|
||||
|
||||
## What is in it
|
||||
|
||||
- **Plugins can ship commanders.** A plugin with `commanders/<name>/COMMANDER.md` offers one; it appears
|
||||
in Command HQ under a new **From plugins** section, arrives **not running**, and must be configured
|
||||
before it can arm.
|
||||
- **av-club ships the first one** — *Convert on arrival*: watches a folder, converts what lands in it.
|
||||
It reaches each host through the standard `bun run plugins:update` step, not through this plan.
|
||||
- Marketplace, file browser and Convert Image work from earlier today rides along.
|
||||
|
||||
## Schema — CHANGED. `db:push` is required.
|
||||
|
||||
```
|
||||
command_hq_sessions
|
||||
+ plugin text null which plugin's template made this commander
|
||||
+ plugin_config jsonb null the owner's answers to that template's inputs
|
||||
```
|
||||
|
||||
Verified as the whole of it:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.29.01..deploy/2026.08.29.02 | grep -iE 'schema|/db/|databases'
|
||||
# → src/databases/officer_db/src/command-hq/queries.ts
|
||||
# src/databases/officer_db/src/command-hq/schema.ts
|
||||
```
|
||||
|
||||
`queries.ts` is code, not schema. The only structural change is the two columns above.
|
||||
|
||||
**Both are nullable with no default and no constraint**, so every existing row is valid the moment they
|
||||
exist and there is nothing to backfill. This is the shape `db:push` handles cleanly — there is no
|
||||
create-then-constrain here, and no `--force` is needed or permitted.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
After the standard steps in `prompts/deploy-commander.md` (dirty check, `git fetch origin --tags`,
|
||||
`git merge --ff-only deploy/2026.08.29.02`, `bun run plugins:update`), in the checkout from PM2's
|
||||
`pm_cwd`:
|
||||
|
||||
### 1. Back the database up first
|
||||
|
||||
```bash
|
||||
pg_dump "$(grep -m1 -E '^(OFFICERDEV_)?POSTGRES_URL=' .env | cut -d= -f2-)" > ~/db-backups/officer-$(date +%Y%m%d-%H%M).sql
|
||||
```
|
||||
|
||||
Read the variable name rather than assuming it: **several hosts use `OFFICERDEV_POSTGRES_URL`**, not
|
||||
`POSTGRES_URL`. `mkdir -p ~/db-backups` first if it is not there.
|
||||
|
||||
### 2. Push the schema
|
||||
|
||||
```bash
|
||||
bun db:push < /dev/null
|
||||
```
|
||||
|
||||
**stdin closed**, so it can never sit waiting on a prompt in an unattended run — it fails instead, which
|
||||
is the outcome you want. Expect `[✓] Changes applied` and then the two post-push scripts.
|
||||
|
||||
### 3. VERIFY BY QUERY. The exit code is not the answer.
|
||||
|
||||
```bash
|
||||
bun -e "
|
||||
const { db } = await import('officerdb/db'); const { sql } = await import('drizzle-orm');
|
||||
const r = await db.execute(sql\`select column_name from information_schema.columns
|
||||
where table_name='command_hq_sessions' and column_name in ('plugin','plugin_config') order by column_name\`);
|
||||
console.log([...((r as any).rows ?? r)].map((x:any)=>x.column_name).join(' '));
|
||||
process.exit(0)"
|
||||
```
|
||||
|
||||
Must print exactly:
|
||||
|
||||
```
|
||||
plugin plugin_config
|
||||
```
|
||||
|
||||
**Put that line in the report, per host.** Not "schema ✓" — the output. A host printing one column, or
|
||||
none, is a **failed host**: report it, move on, and do not attempt a repair you invented.
|
||||
|
||||
### 4. Restart
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only. `plugins:update` has already restarted the plugin sidecars itself.
|
||||
|
||||
### 5. Check it came back
|
||||
|
||||
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are both healthy.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. Its schema is already pushed; nothing else is needed. Name it as skipped. |
|
||||
| `alpha` | Nothing special. |
|
||||
| `fofos` | The one that matters this time — `av-club` is installed there and the commander template is the point of the release. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note. |
|
||||
| `macbruno` | macOS, login `brunoalcadapadezmonteirodaconceicao`. |
|
||||
| `production-pertento-bridge` | Port **9010**. |
|
||||
| `edge-pertento-bridge` | Nothing special. |
|
||||
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, whether the backup was written, `db:push` result, **the exact output of
|
||||
the verification query**, and `officer` back online with a moved `restart_time`.
|
||||
|
||||
The line that matters in the summary: **which hosts print `plugin plugin_config`**. That is the only
|
||||
thing this release actually changes about a machine.
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.27.01",
|
||||
"version": "2026.08.29.02",
|
||||
"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"; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ 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';
|
||||
|
||||
async function getUserTtsVoice(userId: number): Promise<string | null> {
|
||||
try {
|
||||
@@ -210,11 +211,24 @@ 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.
|
||||
*/
|
||||
thumb: isDirectory ? null : thumbnailKind(name),
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -333,6 +347,52 @@ 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 kind = thumbnailKind(relPath);
|
||||
// 415 rather than 404: the file is there, it is simply not something a frame can be taken from. The
|
||||
// client does not ask for these, so this is the answer to a stale tab rather than to normal use.
|
||||
if (!kind) return ctx.text('no thumbnail for this file type', 415);
|
||||
|
||||
const result = await buildThumbnail(absPath, getUserDataDir(user.email), kind, clampSize(ctx.req.query('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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/raw', async (ctx) => {
|
||||
const user = ctx.get('user');
|
||||
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
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.
|
||||
*/
|
||||
const IMAGE_EXT = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'tif', 'tiff', 'svg', 'heic', 'heif']);
|
||||
/*
|
||||
`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,
|
||||
): 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 (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) {
|
||||
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);
|
||||
|
||||
@@ -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'] = [];
|
||||
|
||||
@@ -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}"` };
|
||||
}
|
||||
|
||||
@@ -206,6 +206,45 @@ const handleRevokedSession = (url: string) => {
|
||||
if (revocationHandled) return;
|
||||
revocationHandled = true;
|
||||
|
||||
/*
|
||||
Guard 2b: CONFIRM the session is dead before destroying it.
|
||||
|
||||
A 401 from any one endpoint used to be enough to wipe the token out of both storages and reload —
|
||||
which signs the owner out of every tab, and keeps them signed out, because there is then no token
|
||||
anywhere to recover from. That is the right response to a revoked session and far too violent a
|
||||
response to one endpoint answering 401 for its own reasons.
|
||||
|
||||
Reported 2026-08-27, mid-demo: every refresh and every new tab came back signed out, with nothing in
|
||||
the logs and a freshly signed token working fine against every route the app loads. One transient
|
||||
401 is indistinguishable from a revocation at the call site — so ask the one endpoint whose whole
|
||||
job is to answer it.
|
||||
|
||||
`/auth/me` is exempt from this handler (the `/auth/` guard above), so this cannot recurse.
|
||||
*/
|
||||
void (async () => {
|
||||
try {
|
||||
// Derived from the URL that just failed, because the base is passed per call rather than held in
|
||||
// config — a relative guess would be wrong on any host that mounts the API elsewhere.
|
||||
const probe = /\/api\//.test(url) ? url.replace(/\/api\/.*$/, '/api/auth/me') : '/api/auth/me';
|
||||
const res = await fetch(probe, { headers: { Authorization: `Bearer ${theToken}` } });
|
||||
// Still good: that 401 belonged to one endpoint, not to the session. Leave everything alone and
|
||||
// let the next revocation try again.
|
||||
if (res.ok) {
|
||||
revocationHandled = false;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The server is unreachable — a restart, a dropped network. Emphatically not a revocation, and
|
||||
// wiping the session because the box was rebooting is the failure this guard exists to stop.
|
||||
revocationHandled = false;
|
||||
return;
|
||||
}
|
||||
destroySession();
|
||||
})();
|
||||
};
|
||||
|
||||
/** Actually end it: forget the token everywhere a load could read it back, then reload once. */
|
||||
const destroySession = () => {
|
||||
theToken = null;
|
||||
// Every key `createClient` reads, or the next load signs straight back in with the dead token.
|
||||
for (const key of ['PERTENTO_EDITOR_AUTH_TOKEN', 'BEARER_TOKEN']) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
+29
-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,26 @@ 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>
|
||||
);
|
||||
|
||||
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 128) : 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 +735,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 +789,7 @@ export const FileItem = ({
|
||||
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
|
||||
</div>
|
||||
|
||||
{iconLarge}
|
||||
{gridVisual}
|
||||
|
||||
{renaming ? (
|
||||
<InlineRenameInput
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { 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.
|
||||
*/
|
||||
|
||||
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');
|
||||
|
||||
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}
|
||||
onLoad={() => setState('ready')}
|
||||
onError={() => setState('failed')}
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -139,3 +139,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