| 1 | --- |
| 2 | meta: |
| 3 | title: clover's log |
| 4 | --- |
| 5 | import './log.css'; |
| 6 | static const repo = "${repo}"; |
| 7 | |
| 8 | |
| 9 | [git]: https://git.paperclover.net/ |
| 10 | [home infra]: https://git.paperclover.net/clo/home-infra |
| 11 | [name paint bot]: https://git.paperclover.net/clo/discord-name-painter |
| 12 | [next.js blog post]: /blog/webdev/one-year-next-app-router |
| 13 | [progress.ts]: https://jsr.io/@clo/lib/doc/progress |
| 14 | [this site]: https://paperclover.net/ |
| 15 | [todo tracker]: https://git.paperclover.net/clo/todo-tracker |
| 16 | [ts lie detector]: https://git.paperclover.net/clo/ts-lie-detector |
| 17 | [evil inc]: https://evil.inc/ |
| 18 | [history of japan reanimated]: /history-of-japan-reanimated |
| 19 | [react mutation]: http://jsr.io/@clo/react-mutation |
| 20 | [react markdown]: http://jsr.io/@clo/react-markdown |
| 21 | [HOTEWIG reanimated]: /hotewig-reanimated |
| 22 | [clover creative control]: https://git.paperclover.net/clo/creative-control |
| 23 | [markodown]: https://git.paperclover.net/clo/markodown |
| 24 | |
| 25 | <main> |
| 26 | |
| 27 | [go back to the home page](/) |
| 28 | |
| 29 | # clover's log |
| 30 | |
| 31 | these are like mini blog posts, but more in generally just what the heck i'm up to. |
| 32 | |
| 33 | ## 2026-03-20 |
| 34 | |
| 35 | tags: [react markdown] |
| 36 | |
| 37 | i published a new library named `@clo/react-markdown`. originally, i wanted to |
| 38 | have my own version of the `streamdown` package from vercel. but i didn't |
| 39 | realize how much of a loser company they all are. i wasn't even trying and i |
| 40 | made a library like a hundred times better and simpler than theres. because of |
| 41 | the awesome success here, instead of calling mine "memo markdown", i just said |
| 42 | "yea, this covers every markdown use case for react" and called it React Markdown. |
| 43 | |
| 44 | you can install it from the JSR: |
| 45 | |
| 46 | ```sh |
| 47 | npx jsr add @clo/react-markdown |
| 48 | pnpm add jsr:@clo/react-markdown |
| 49 | ``` |
| 50 | |
| 51 | there are two main features that i deliver on: |
| 52 | |
| 53 | - predicting close tokens for sequences like `hello **world`, appending `**` |
| 54 | - streamdown has this too, but many many cases are not considered. while |
| 55 | theirs is extensible and mine isn't, i don't think you'll need to extend |
| 56 | my markdown predictor. |
| 57 | - component memoization. all block and inline components will preserve their |
| 58 | state, even as adjacent content changes. this is done to preserve remounts |
| 59 | for things like custom `<a>` tags or other components. (for example, if a |
| 60 | custom `<a>` fetches previewing data and provides a hover card, that card |
| 61 | won't flicker). |
| 62 | |
| 63 | copying some architecture notes from the readme, the memoizer is performant |
| 64 | from the following tricks: |
| 65 | |
| 66 | - Using proper `React.memo()` calls. Obviously. |
| 67 | - Prediction is implemented in a stateful way that only parses the tail end of |
| 68 | the document, marking how much of the document is stable and where possible |
| 69 | incomplete syntax may live. Since prediction only applies at the end, changing |
| 70 | text midway through can invalidate the whole predictor. |
| 71 | - Separate the parsed document into "blocks", noting the source location of |
| 72 | where each block lives. |
| 73 | - Only start parsing after the first changed character, rounded to the nearest |
| 74 | block. In the append-only stream, this essentially means the last two blocks |
| 75 | are the only things being re-parsed. |
| 76 | - Similarly, run AST transforms only on the changed data. This step has a couple |
| 77 | of slow paths for when reference link definitions are added or edited, since |
| 78 | it means any places that might have used a reference link may now have to |
| 79 | reflect it. |
| 80 | - After all that, a special AST -> React node transform is used that diffs the |
| 81 | new ast with the last ast, reusing React nodes whenever possible. It supports |
| 82 | nested children as well as re-ordering top level blocks. This is what prevents |
| 83 | most rerenders and is the "secret sauce". |
| 84 | |
| 85 | ## 2026-03-18 |
| 86 | |
| 87 | tags: [progress.ts] |
| 88 | |
| 89 | laser hair removal is awesome btw. organizing things at home slowly. more work |
| 90 | on the progress library, trying to handle every edge case possible for the log |
| 91 | widget system. |
| 92 | |
| 93 | when it is done, a code snippet like this will work. |
| 94 | |
| 95 | ```ts |
| 96 | using node = progress.start("some action"); |
| 97 | for await (const token of stream) { |
| 98 | // correctly interweave progress TUI with partial log lines |
| 99 | process.stderr.write(token); |
| 100 | } |
| 101 | ``` |
| 102 | |
| 103 | the log system injects into `process` to ensure it plays nice, but there is |
| 104 | only a fast path on stdout (since the main log messages uses stdout). stderr |
| 105 | gets to use the crazy `getDrawLock` API i'm cooking up internally. |
| 106 | |
| 107 | after all of this works and is reliable, there are some more things i have to |
| 108 | tidy, but then the progress blog post can be written and reviewed for realsies. |
| 109 | |
| 110 | ## 2026-03-16 |
| 111 | |
| 112 | just chilling. at work i replaced `bun install` with `pnpm`. pretty peak. in the |
| 113 | night, i worked on [this page][this site], and worked a bit on [hexiflare]'s |
| 114 | local llm system. |
| 115 | |
| 116 | ## 2026-03-15 |
| 117 | |
| 118 | tags: [clover creative control] |
| 119 | |
| 120 | this is sort of the new version of what i've called "creative toolkit." it's now |
| 121 | a TypeScript library that controls REAPER and the DaVinci Resolve Speed Editor. |
| 122 | the two layers are the library layer and the configuration layer. the config |
| 123 | layer is meant to be trivial, editable and instantly-reloadable at a moments notice. |
| 124 | |
| 125 | ```ts |
| 126 | export default config.forApp("com.cockos.reaper", ({ speededitor: se, mac }) => { |
| 127 | const reaper = new Reaper(); |
| 128 | |
| 129 | // Sync state to LEDs |
| 130 | reaper.on("transport", (transport) => { |
| 131 | se.leds.cut = transport.recording; |
| 132 | se.leds.dissolve = transport.recording; |
| 133 | se.leds.smoothCut = transport.recording; |
| 134 | }); |
| 135 | |
| 136 | // Keyboard Actions |
| 137 | se.onPress("stopPlay", () => { |
| 138 | if (reaper.transport.recording) { |
| 139 | reaper.runAction("transport-stop-save-all-recorded-media"); |
| 140 | } else { |
| 141 | reaper.runAction("transport-play-stop"); |
| 142 | } |
| 143 | }); |
| 144 | se.onPress("cut", () => { |
| 145 | if (reaper.transport.recording) { |
| 146 | reaper.runAction("transport-stop-delete-all-recorded-media"); |
| 147 | reaper.runAction("transport-record"); |
| 148 | } else { |
| 149 | reaper.runAction("transport-record"); |
| 150 | } |
| 151 | }); |
| 152 | se.onPress("dissolve", () => { |
| 153 | reaper.runAction("transport-stop-delete-all-recorded-media"); |
| 154 | }); |
| 155 | se.onPress("smoothCut", () => { |
| 156 | if (reaper.transport.recording) { |
| 157 | reaper.runAction("transport-stop-save-all-recorded-media"); |
| 158 | reaper.runAction("transport-record"); |
| 159 | } |
| 160 | }); |
| 161 | se.onPress("videoOnly", () => { |
| 162 | if (mac.window?.title.startsWith("FX:") && mac.mainWindow) { |
| 163 | mac.focusMainWindow(); |
| 164 | return; |
| 165 | } |
| 166 | reaper.runAction("track-view-fx-chain-for-current-last-touched-track"); |
| 167 | }); |
| 168 | }); |
| 169 | ``` |
| 170 | |
| 171 | the winning workflow here for recording audio has been the bottom three keys being mapped to |
| 172 | |
| 173 | - start a recording. or if one is already going, delete and restart it |
| 174 | - discard the currently recording take and stop |
| 175 | - accept the currently recording and start a new one |
| 176 | |
| 177 | this is better than just having keys on the main keyboard, no modifiers! *and* i |
| 178 | don't have to lose out on any existing keybinds. |
| 179 | |
| 180 | i used to have a setup like this with multiple external keyboards, but i am |
| 181 | keeping that on hold until i run out of macro keys. what's also interesting to |
| 182 | note is each app gets its own layer of bindings. |
| 183 | |
| 184 | ## 2026-03-14 |
| 185 | |
| 186 | tags: [album] |
| 187 | |
| 188 | i've made some beautiful progress on the album, i finished another song. it is |
| 189 | one of my best creative works i've ever done. i'm personally very happy with my |
| 190 | singing and vocal editing skills improving. will be holding it captive except in |
| 191 | some small circles (who have all said it is amazing) |
| 192 | |
| 193 | ## 2026-03-11 |
| 194 | |
| 195 | tags: [react mutation] |
| 196 | |
| 197 | my coworker was working on something that needed a feature for react mutation. i |
| 198 | also needed his approval on some code i was working on, but he wouldn't give me |
| 199 | any attention. so as a bribe, i released a new patch of the library. my PR was |
| 200 | reviewed, and everything was great. the changelog |
| 201 | |
| 202 | - tanstack query integration |
| 203 | - [feat: `arrayUpsert` and `objArrayUpsert`](https://git.paperclover.net/clo/react-mutation/commit/47304f56e98bf841b757d16e472fb64a01c99fd2) |
| 204 | - [fix: allow spreading `TanstackQueryOptimisticHelpers`](https://git.paperclover.net/clo/react-mutation/commit/4b09ff4dba5fad5b7e2b44a0bdc2789918f74561) |
| 205 | - mutation buttons |
| 206 | - [pass `args={null}` to disable the button UI](https://git.paperclover.net/clo/react-mutation/commit/ec65067d04a72a51b71b63d3b4569843b8fd0dad) |
| 207 | |
| 208 | ## 2026-03-10 |
| 209 | |
| 210 | tags: [hexiflare] |
| 211 | |
| 212 | with the hexi bot with just two users eating my entire at-work anthropic |
| 213 | subscription, i was messing with getting hexi to work as much on local models as |
| 214 | possible. the results are very promising, but it is very much becoming a |
| 215 | manually designed and written project, which is good (slop is bad). but that |
| 216 | does mean i can't spent a lot of time on it, as i have other desires. |
| 217 | |
| 218 | anyways, the local model chat system works by having a qwen model with an insane |
| 219 | "personality" prompt. and then it goes through another qwen model with an |
| 220 | equally insane "review". the results are pretty good. |
| 221 | |
| 222 | ``` |
| 223 | you> what u been up to? |
| 224 | hexi> not much lol |
| 225 | hexi> messing w/ some css animations rn breaking them is kinda fun though |
| 226 | you> omg can i see? |
| 227 | hexi> hold on lemme whip up a lil demo page real quick |
| 228 | [triggers sandboxed agent task] |
| 229 | ``` |
| 230 | |
| 231 | benefit of doing it this way instead of through a stupid wrapper, the latency |
| 232 | can be brought down a ton. but the full system isn't wired up, and i frankly |
| 233 | don't trust anyone but myself to do this task. |
| 234 | |
| 235 | ## 2026-03-05 |
| 236 | |
| 237 | tags: [work], [hexiflare] |
| 238 | |
| 239 | i wrote a beautiful dialog component for use at work and in the hexiflare |
| 240 | template. the usage is like this: |
| 241 | |
| 242 | ```ts |
| 243 | showTextDialog({ |
| 244 | title: "Hello hiii heelloooo.", |
| 245 | description: "Enter something important" |
| 246 | onConfirm: async (reason) => { |
| 247 | // do something with `reason` |
| 248 | }, |
| 249 | onCancel: "close", |
| 250 | }); |
| 251 | ``` |
| 252 | |
| 253 | it's certainly better than copying the Radix Dialog everywhere. that component |
| 254 | is a great primitive but it isn't nice to spam it. |
| 255 | |
| 256 | |
| 257 | ## 2026-02-25 |
| 258 | |
| 259 | tags: [hexiflare] |
| 260 | |
| 261 | i was starting to work on an ai project, a chatbot that is absurdly tuned to the |
| 262 | format of texting. in addition to this, it has full access to a (sandboxed) |
| 263 | computing environment where it can run any command, browser use, and also can |
| 264 | deploy web apps. |
| 265 | |
| 266 | this whole thing is based on a friend's project, named "clungus", which was |
| 267 | literally just a wrapper around claude code. his could deploy static websites, |
| 268 | which was really cool since you could just ask it for some little thing and it |
| 269 | would just make it. and you send your bug reports as text messages. |
| 270 | |
| 271 | in both bots, the interface is much more natural than using claude code, |
| 272 | chatgpt, or another application. and with mine particularly, i was working on |
| 273 | making the base app template as high quality as possible, so that the apps. so |
| 274 | at a minimum, we now have one of the best tanstack start templates ever. at |
| 275 | best, i might be able to automate some apps i never would've cared to write |
| 276 | manually. |
| 277 | |
| 278 | ## 2026-02-25 |
| 279 | |
| 280 | ## 2026-02-18 |
| 281 | |
| 282 | tags: [this site], [markodown] |
| 283 | |
| 284 | i finally closed [issue #1](https://git.paperclover.net/clo/sitegen/issues/1) on |
| 285 | my website, eliminating the MDX compiler in its entirety on my website. |
| 286 | |
| 287 | i don't think MDX is a great format to work with. and on top of that, it adds |
| 288 | 111 transitive dependencies to my website. and what, all for a medium markdown |
| 289 | language. markdown is inherently concise, so pairing it with Marko felt like it |
| 290 | would be a nicer format to work in. the answer: it is! i made "markodown", by |
| 291 | using rust, a bit of llm slop, and used it a ton to ensure a good api. the |
| 292 | result is beautiful. |
| 293 | |
| 294 | my favorite part of this language is automatic header tracking being built in. |
| 295 | alongside Marko's id shorthand, i can type something like this: |
| 296 | |
| 297 | ```ts |
| 298 | <h2#technical-review>A Technical Review: What are Server Components?</> |
| 299 | ``` |
| 300 | |
| 301 | and the compiler will track this properly, emitting it as |
| 302 | `<Heading level=2 id="technical-review">` and including it in the generated |
| 303 | table of contents. i can then include the table of contents with a |
| 304 | `<table-of-contents />` element |
| 305 | |
| 306 | a little bit tough because i did spend a whole week on it. but it's done, and i |
| 307 | love it. after this port, i also axed some other dependencies in favor of |
| 308 | lighter alternatives: |
| 309 | |
| 310 | - `puppeteer` with `playwright` |
| 311 | - `codemirror` with `<textarea />` |
| 312 | - panning to replace `msgpackr` with a custom format that handles the types needed |
| 313 | |
| 314 | and the biggest of all, deprecating my HTML rendering framework, in favor of |
| 315 | using Marko. this is technically a complexity increase, except for the part |
| 316 | where i already supported Marko. |
| 317 | |
| 318 | ## 2026-02-08 |
| 319 | |
| 320 | tags: [HOTEWIG reanimated] |
| 321 | |
| 322 | i am on the team of people organizing "history of the entire world, i guess - |
| 323 | reanimated." not much news yet, but it will be exciting. please fill out the |
| 324 | form if you are interested, and please send it to anyone you may think would be |
| 325 | interested. |
| 326 | |
| 327 | ## 2026-02-06 |
| 328 | |
| 329 | tags: [this site] |
| 330 | |
| 331 | spent yesterday and today catching up with this page and preparing to get |
| 332 | writing heavy on the blog. i also fixed a bug on the video player where it |
| 333 | wouldnt work on latest chrome on non-av1 accelerated hardware. |
| 334 | |
| 335 | there were two bugs: |
| 336 | |
| 337 | - chrome without av1 and version >=142 would try and play the hls stream |
| 338 | natively. they made `video.canPlayType("application/x-mpegURL")` return |
| 339 | "maybe", which is total propaganda because chrome does not actually support |
| 340 | this type of stream. they support some insane subset that isn't real world |
| 341 | use cases. |
| 342 | - hls polyfill was never being used because of a bundling mistake, so those |
| 343 | users would get the unoptimized original file. |
| 344 | |
| 345 | since i was testing windows 7, decided to also fix some IE9 bugs. the page |
| 346 | works besides the video player and the fonts, which is all things i believe i |
| 347 | could easily fix, so i opened new issues for those tasks. |
| 348 | |
| 349 | ## 2026-02-04 |
| 350 | |
| 351 | tags: [this site] |
| 352 | |
| 353 | i did a ton of random stuff to [this site], including the RSS feed and random |
| 354 | bug fixes on the domain. trying to reduce the issue count on my forgejo. |
| 355 | |
| 356 | ## 2026-02-03 |
| 357 | |
| 358 | tags: [history of japan reanimated] |
| 359 | |
| 360 | immediately after the youtube premiere, i worked on my webpage of it. the live |
| 361 | credits was a really fun touch that really completes things. i'm very happy |
| 362 | with the final result. |
| 363 | |
| 364 | i also had to do a ton of work fixing the video player to properly show |
| 365 | thumbnails on the video player. the biggest one was adding even more hell to |
| 366 | the dash-av1 player. a issue that `dash.js` has is providing the first frame of |
| 367 | media before the user presses "play." this doesn't happen with the hls or |
| 368 | native players. so my workaround... delay attachment of the view. |
| 369 | |
| 370 | ``` |
| 371 | player.initialize(null, dashFile, false); |
| 372 | player.updateSettings({ |
| 373 | streaming: { cacheInitSegments: true }, |
| 374 | }); |
| 375 | player.preload(); |
| 376 | |
| 377 | video.addEventListener("play", function play() { |
| 378 | video.removeEventListener("play", play); |
| 379 | |
| 380 | enableSafariAirplay(); // <-- another important hack |
| 381 | player.attachView(video); |
| 382 | onCloverVideoInit?.(id, video); |
| 383 | }); |
| 384 | video.controls = true; |
| 385 | ``` |
| 386 | |
| 387 | but on chrome you get cooked because a video without a source is not playable. |
| 388 | so a second hack used to attach a fake source. |
| 389 | |
| 390 | ``` |
| 391 | const duration = new Promise<number>((resolve) => { |
| 392 | player.on(dashjs.MediaPlayer.events.MANIFEST_LOADED, (e) => { |
| 393 | const duration = e.data.mediaPresentationDuration; |
| 394 | resolve(duration); // seconds |
| 395 | }); |
| 396 | }); |
| 397 | mediaSource = new MediaSource(); |
| 398 | mediaSource.addEventListener("sourceopen", () => { |
| 399 | duration.then((duration) => { |
| 400 | mediaSource = mediaSource!; // typescript assertion |
| 401 | mediaSource.duration = duration; |
| 402 | const sb = mediaSource.addSourceBuffer('video/webm; codecs="vp8"'); |
| 403 | const oneFrame = Uint8Array.from( |
| 404 | atob("GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAITEU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHWTbuMU6uEElTDZ1OsggEjTbuMU6uEHFO7a1OsggH97AEAAAAAAABZ" + "A".repeat(119) + "VSalmsCrXsYMPQkBNgIxMYXZmNjIuMy4xMDBXQYxMYXZmNjIuMy4xMDBEiYhAXgAAAAAAABZUrmvIrgEAAAAAAAA/14EBc8WIDV7YG1KxA/CcgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4QCYloA4JCwgQG6gQGagQJVsIRVuYEBElTDZ/tzc59jwIBnyJlFo4dFTkNPREVSRIeMTGF2ZjYyLjMuMTAwc3PWY8CLY8WIDV7YG1KxA/BnyKFFo4dFTkNPREVSRIeUTGF2YzYyLjExLjEwMCBsaWJ2cHhnyKFFo4hEVVJBVElPTkSHkzAwOjAwOjAwLjEyMDAwMDAwMAAfQ7Z11eeBAKOigQAAgBACAJ0BKgEAAQALxwiFhYiFhIg/ggAMDWAA/ua1AKOVgQAoALEBAC8R/AAYABhYL/QAJAAAo5WBAFAAsQEALxH8ABgAGFgv9AAkAAAcU7trkbuPs4EAt4r3gQHxggGj8IED"), |
| 405 | (x) => x.charCodeAt(0), |
| 406 | ); |
| 407 | sb.appendBuffer(oneFrame); |
| 408 | }); |
| 409 | }); |
| 410 | objectUrl = URL.createObjectURL(mediaSource); |
| 411 | |
| 412 | mainSource = document.createElement("source"); |
| 413 | mainSource.src = objectUrl; |
| 414 | video.appendChild(mainSource); |
| 415 | ``` |
| 416 | |
| 417 | the payload within contains a single webm vp8 frame, one pixel by one pixel. |
| 418 | this is enough to get chrome to shut up. now the experience is nearly perfect, |
| 419 | with browser support all the way to the ancient firefox version on my old |
| 420 | laptop, to modern Apple Silicon Macs. |
| 421 | |
| 422 | ## 2026-01-31 |
| 423 | |
| 424 | tags: [history of japan reanimated] |
| 425 | |
| 426 | main section of animation for the project was already done, but i wanted to |
| 427 | make some needed adjustments to my scene. so i had been doing those for a few |
| 428 | days, as well as animating an extra 25 seconds for the outro section. it was |
| 429 | very fun doing that process, since i actually grabbed an old macbook i had in |
| 430 | storage to take the textedit screenshot. more details on this entire process |
| 431 | will be in the project page. |
| 432 | |
| 433 | ## 2026-01-30 |
| 434 | |
| 435 | tags: [react mutation] |
| 436 | |
| 437 | at work, one of my coworkers was complaining about my helper functions for |
| 438 | TanStack Query's mutation system. the conclusion was that the mutation system |
| 439 | we were building on was flawed and not enjoyable to use. so i spent a few days |
| 440 | making an alternative library. it's on the JSR: [@clo/react-mutation](https://jsr.io/@clo/react-mutation). |
| 441 | |
| 442 | this project was really fun because of how much the API surface changed as i |
| 443 | started staging the library into our actual code. the experience has shaped how |
| 444 | i want to go about my upcoming blog post for the better. |
| 445 | |
| 446 | ## 2026-01-25 |
| 447 | |
| 448 | tags: [ts lie detector], [todo tracker] |
| 449 | |
| 450 | added a trivial binary to the lie detector, `tsld-node`, which is like `ts-node` or `tsx` but it runs with the lie detector. |
| 451 | |
| 452 | for todo tracker, it's been vibe coded to a point where the sitegen repo gets |
| 453 | through all commits, but there are still some issues with missing TODOs. i haven't really had time to prioritize this, even with an ai agent writing most of it, since even then i have to review the progress of it, so it's just not worth my time until other projects pull through. |
| 454 | |
| 455 | ## 2026-01-24 |
| 456 | |
| 457 | tags: [git], [home infra] |
| 458 | |
| 459 | i moved forgejo off of sqlite and onto postgres. the only motiviation behind this was to easily backdate the repositories i had imported: [name paint bot] and a scripting language compiler i wrote. i was more comfortable doing this on a real database server than just editing the file. |
| 460 | |
| 461 | the migration took a bit for me to figure out, but last year someone named Sven [did the same thing](https://sven-seeberg.de/wp/?p=1213), and found this `pgloader` command with the critical `data only` clause. |
| 462 | |
| 463 | ``` |
| 464 | echo "LOAD DATABASE |
| 465 | FROM sqlite:///root/forgejo.db |
| 466 | INTO postgresql://forgejo:$POSTGRES_PASSWORD_FORGEJO@postgres/forgejo |
| 467 | WITH data only, reset sequences, prefetch rows = 10000 |
| 468 | SET work_mem TO '16MB', maintenance_work_mem TO '512MB';" > ./pgloader-command |
| 469 | |
| 470 | pgloader ./pgloader-command |
| 471 | ``` |
| 472 | |
| 473 | ## 2026-01-18 |
| 474 | |
| 475 | tags: [git], [home infra] |
| 476 | |
| 477 | i finally setup system integrated ssh push, but with a twist. |
| 478 | |
| 479 | the first problem is having two ssh servers on the same machine, one for the |
| 480 | host, and another for Forgejo. this means that one of them had to live on |
| 481 | another port, so i chose to move the host. but it kind of just sucks to use |
| 482 | this. the proper solution is to use a custom config to direct the `git` user to |
| 483 | the right place. |
| 484 | |
| 485 | what made this harder is i actually had two git instances; the second one is |
| 486 | for a temporary infrastructure i'm running for [evil inc] until the |
| 487 | organization gets dedicated hardware (more about this in a future post). so |
| 488 | even if i routed `git` specially, it still wouldnt know which git server to go |
| 489 | to. |
| 490 | |
| 491 | **the solution**: write a custom "router" script that generates an |
| 492 | `authorized_keys` file based on which git instances actually have a key, then |
| 493 | the line within the `authorized_keys` forces a special wrapper which intercepts |
| 494 | the targetted git repository, routing it to the git instance with it. |
| 495 | |
| 496 | the sshd config looks like |
| 497 | |
| 498 | ``` |
| 499 | Match User git |
| 500 | AuthorizedKeysCommand /bin/python /mnt/storage1/apps/home-infra/config/forgejo/ssh/keys.py %u %t %k |
| 501 | AuthorizedKeysCommandUser git |
| 502 | ``` |
| 503 | |
| 504 | it is convenient because `AuthorizedKeysCommand` is run on every connection. it produces a file with zero or one valid keys: |
| 505 | |
| 506 | ``` |
| 507 | command="/mnt/storage1/apps/home-infra/config/forgejo/ssh/route.sh --clover 1 --evil 2",no-port-forwarding,...,restrict ssh-ed25519 AAAAC3NzaC... |
| 508 | ``` |
| 509 | |
| 510 | and then the route script does this shit: |
| 511 | |
| 512 | ``` |
| 513 | if [ -d "/mnt/storage1/apps/forgejo/git/repositories/${repo}" ]; then |
| 514 | [ -z "$clover_id" ] && echo "ssh key is not configured for repository on git.paperclover.net" >&2 && exit 1 |
| 515 | exec sudo docker exec -i -u git forgejo /usr/bin/env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" /usr/local/bin/forgejo --config=/custom/conf/app.ini serv key-"$clover_id" |
| 516 | elif [ -d "/mnt/storage1/apps/evil-infra/forgejo/git/repositories/${repo}" ]; then |
| 517 | [ -z "$evil_id" ] && echo "ssh key is not configured for repository on git.evil.inc" >&2 && exit 1 |
| 518 | exec sudo docker exec -i -u git evil-forgejo /usr/bin/env SSH_ORIGINAL_COMMAND="$SSH_ORIGINAL_COMMAND" /usr/local/bin/forgejo --config=/custom/conf/app.ini serv key-"$evil_id" |
| 519 | else |
| 520 | echo "repo not found" >&2 |
| 521 | exit 1 |
| 522 | fi |
| 523 | ``` |
| 524 | |
| 525 | it works beautifully. [see the whole patch for more info](https://git.paperclover.net/clo/home-infra/commit/e402d6ef71dfc310caeeb4d3579bddd9d0710594) |
| 526 | |
| 527 | ## 2026-01-14 |
| 528 | |
| 529 | tags: [next.js blog post], [this site] |
| 530 | |
| 531 | general housekeeping. getting this activity page up. getting the spanish |
| 532 | translation by my good friend trubiso up. things are looking really cozy. |
| 533 | |
| 534 | ## 2026-01-12 |
| 535 | |
| 536 | tags: [album] |
| 537 | |
| 538 | i started more music work. i've gotten better at lyric writing, phrasing this |
| 539 | new song as a sort of "adventure". felt for one of the first times that i was |
| 540 | doing worldbuilding in a song. the imagery is that good. |
| 541 | |
| 542 | ## 2026-01-11 |
| 543 | |
| 544 | tags: [progress.ts], [todo tracker] |
| 545 | |
| 546 | i finished streaming io on [progress.ts]. very proud of it. my git |
| 547 | commits describe the tech better than me reiterating. |
| 548 | |
| 549 | > ## feat(lib/progress): implement streaming wire protocol |
| 550 | > resolves `#47` |
| 551 | > |
| 552 | > `encodeByteStream` converts these events into a `ReadableStream`. by |
| 553 | > batching events together, the stream contents remain small, that way the |
| 554 | > code that constructs progress nodes do not have to worry about calling |
| 555 | > many setters at once, it gets debounced be the serializer. stream |
| 556 | > backpressure causes larger time-gaps to be batched (smaller). this |
| 557 | > enables servers to respond with rich progress. |
| 558 | > |
| 559 | > ``` |
| 560 | > const root = new progress.Root(); |
| 561 | > doActionWithProgress(root).then(root.end, root.error); |
| 562 | > // streaming clients indicate a header |
| 563 | > if (req.headers.get("Accept")?.includes(progress.contentType)) |
| 564 | > return new Response(progress.encodeByteStream(root), { |
| 565 | > headers: { 'Content-Type': progress.contentType }, |
| 566 | > }); |
| 567 | > // to support non-streaming clients |
| 568 | > return Response.json(await root.asPromise()); |
| 569 | > ``` |
| 570 | > |
| 571 | > and `decodeByteStream` on the client: |
| 572 | > |
| 573 | > ``` |
| 574 | > const output = document.getElementById("output"); |
| 575 | > const res = await fetch(...); |
| 576 | > if (!res.ok) throw ...; |
| 577 | > const root = new progress.Root(); |
| 578 | > root.on("change", (active) => { |
| 579 | > output.innerText = ansi.strip(progress.formatAnsi( |
| 580 | > performance.now(), |
| 581 | > active, |
| 582 | > )); |
| 583 | > }); |
| 584 | > const result = await progress.decodeByteStream(res.body, root); |
| 585 | > output.innerText = JSON.stringify(result); |
| 586 | > ``` |
| 587 | > |
| 588 | > there is currently no document bindings, but i plan to. additionally, a |
| 589 | > React hook is very trivial to implement for this -- but that is |
| 590 | > unplanned for this repository. for transports that require JSON or |
| 591 | > UTF-8, there is `encodeEventStream` which returns a `ReadableStream` of |
| 592 | > JSON objects which can be compressed at the developer's discretion. |
| 593 | |
| 594 | > ## feat(lib/progress): headless rendering + time estimation |
| 595 | > node signaling is done by providing a `progress.Root` to every node, |
| 596 | > dispatching events to it when the node changes. the root is connected to |
| 597 | > an observer to construct a UI out of it. there are two apis planned: |
| 598 | > |
| 599 | > - `attachToScreen` binds a root to a TTY screen (via the log.Widget API). |
| 600 | > the primary use of this is to implement the top level `progress.start`. |
| 601 | > |
| 602 | > - a serialization system that allows transmitting a `Root` over a wire. |
| 603 | > this commit was going to include this but it is an unexpectedly large |
| 604 | > component. |
| 605 | > |
| 606 | > - potentially a browser binding like `attachToDocument`. this will not |
| 607 | > be added in this patch. |
| 608 | > |
| 609 | > additionally, resolves #33 by implementing `estimatedTime` |
| 610 | |
| 611 | i also did a large part of the work to create a "code todo tracking" tool. i |
| 612 | would say it's about half done, since the second half is simply fixing all of |
| 613 | the little bugs there are. most of this code is currently ai-generated, but |
| 614 | with me manually coming in to write interfaces and the modular program |
| 615 | architecture. then i synthesize the code and the tests. this was basically just |
| 616 | going on ambiently while [progress.ts] was in progress. |
| 617 | |
| 618 | ## 2026-01-09 |
| 619 | |
| 620 | tags: [home infra] |
| 621 | |
| 622 | finished SSO sub-project. im happy with the setup i used to protect internal |
| 623 | services, such as pgadmin and qbittorrent. it's a caddy snippet that i can |
| 624 | re-use very easily. |
| 625 | |
| 626 | ``` |
| 627 | (reverse_proxy_auth) { |
| 628 | handle /snow.oauth2/* { |
| 629 | 		reverse_proxy "http://forward-auth" { |
| 630 | 			header_up X-Real-IP {remote_host} |
| 631 | 			header_up X-Forwarded-Uri {uri} |
| 632 | 		} |
| 633 | 	} |
| 634 | handle { |
| 635 | forward_auth "http://forward-auth" { |
| 636 | uri /snow.oauth2/auth |
| 637 | header_up X-Real-IP {remote_host} |
| 638 | @error status 401 |
| 639 | handle_response @error { |
| 640 | redir * /snow.oauth2/sign_in?rd={scheme}://{host}{uri} |
| 641 | } |
| 642 | @valid_group header X-Auth-Request-Groups *role:{args[1]}* |
| 643 | handle_response @valid_group { |
| 644 | method {method} |
| 645 | rewrite {uri} |
| 646 | reverse_proxy {args[0]} { |
| 647 | header_up Cookie ([^;]*?)\s*_oauth2_proxy_\d=[^;]*(;?.*) "$1$2" |
| 648 | {block} |
| 649 | } |
| 650 | } |
| 651 | handle_response { |
| 652 | rewrite /403.html |
| 653 | file_server { |
| 654 | status 403 |
| 655 | root /etc/caddy |
| 656 | } |
| 657 | } |
| 658 | } |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | # usage |
| 663 | pg.{$HOME_DOMAIN} { |
| 664 | import reverse_proxy_auth "http://pgadmin" admin |
| 665 | } |
| 666 | qbt.{$HOME_DOMAIN} { |
| 667 | import reverse_proxy_auth "http://qbittorrent" media-manage |
| 668 | } |
| 669 | ``` |
| 670 | |
| 671 | |
| 672 | ## 2026-01-04 |
| 673 | |
| 674 | tags: [home infra] |
| 675 | |
| 676 | working on SSO for my internal services. for context, i have about 12 |
| 677 | self-hosted services running, half of which i allow my friends to access. |
| 678 | currently, this is done through manually creating an account on such service |
| 679 | (jellyfin, forgejo), but many are done through a caddy rule. in the interest of |
| 680 | making my password manager less confused (ip vs domain, subdomain etc), i'm |
| 681 | slowly reducing this setup to a single sign in page. |
| 682 | |
| 683 | to do this, i am using https://keycloak.org, which supports openid connect |
| 684 | (how i will configure forgejo and jellyfin), as well as a separate service to |
| 685 | provide forward auth proxying (how i protect services like copyparty, |
| 686 | syncthing, pgadmin, and many more). i tried authelia beforehand, but i really |
| 687 | do not recommend them due to how hard it is to configure, passkeys being |
| 688 | annoying to setup, and limited themes. i also dont recommend authentik, but i |
| 689 | couldnt figure out how to even start using it after i installed it. |
| 690 | |
| 691 | keycloak is a bit stupid on config. as all the config lies in the postgres |
| 692 | database, i can't use a config file to setup the primary realm. so instead, i |
| 693 | have this huge python script to use the API to upsert the configuration in. |
| 694 | this works pretty well, and means that for locally running the infrastructure |
| 695 | for testing, i can get the config to be the same (useful if you brick |
| 696 | keycloak, which is pretty easy to do). |
| 697 | |
| 698 | ## 2026-01-02 |
| 699 | |
| 700 | tags: [home infra], [git], [name paint bot] show |
| 701 | |
| 702 | i deleted all my github repositories except four: my "readme", a bug |
| 703 | reproduction repo, the mirror for [ts lie detector], and a shared private repo |
| 704 | with someone that is load bearing. in this process, i've moved all the projects |
| 705 | to my [forgejo instance][git]. |
| 706 | |
| 707 | with this, [name paint bot], one of my few remaining projects that is still |
| 708 | active, moves to that forgejo instance using their github migrator. some of my |
| 709 | private projects, like my pet scripting language, were migrated as well. it |
| 710 | feels more alive on my site because of the theming and per-repo icons. |
| 711 | |
| 712 | after a year of forgejo, i am really happy with how it treats me. |
| 713 | |
| 714 | </main> |