Syncing Ten Ports With Upstream: Glow v3, spec-kit 1.0, and Everything In Between
This year we ported ten libraries to TypeScript: six from Charm's Go ecosystem (Lip Gloss, Glamour, Bubble Tea, Bubbles, Huh, Glow), go-rod's browser launcher, and three from Python (bm25s, PageIndex, spec-kit). Each one shipped with its own article.
A port is a snapshot, and upstream keeps moving. In April we synced specify-cli with spec-kit v0.7.0. By late September spec-kit was at 1.0.12, Glow had shipped v3, Bubbles had a tree component, and PageIndex had cut nineteen releases.
So we ran that same playbook across all ten at once.
Ten ports, one pass: 1,795 tests became 5,730, and no new runtime dependencies.

I – Where Everything Stood
| Port | Our baseline | Upstream now | New version |
|---|---|---|---|
@oakoliver/lipgloss |
v2.0.5 | v2.0.6 | 1.1.2 |
@oakoliver/glamour |
v2.0.1 | v2.0.1 (renderer re-ported) | 2.0.0 |
@oakoliver/bubbletea |
v2.0.8 | v2.0.10 | 1.2.0 |
@oakoliver/bubbles |
v2.1.1 | v2.2.1 | 1.2.2 |
@oakoliver/huh |
v2.0.3 | v2.0.3 + 2 fixes on main | 1.1.1 |
@oakoliver/glow |
v2.1.1 | v3.0.0 | 2.0.1 |
@oakoliver/browser-launcher |
go-rod v0.116.2 | main, 14 commits later | 1.1.0 |
bm25s |
0.3.2 | 0.3.11 | 2.0.0 |
pageindex |
commit 959452d |
v0.2.19 | 2.0.0 |
@oakoliver/specify-cli |
spec-kit v0.7.0 | v1.0.12 | 2.0.0 |
Glamour was the only upstream that hadn't moved. Everything else had something to bring over, from two fixes in Huh to 1,291 commits in spec-kit. Glamour still got a major version, for a reason that comes up in section III.
II – The Playbook
Every sync followed the same five steps.
- Find the baseline. You can't diff without one, and half of these repos didn't record it. For bm25s it came from features: a counting-sort index builder (upstream 0.3.0) and Turkish stopwords (0.2.14), but no Korean ones (0.3.4), which pins it to 0.3.2. PageIndex had no tags before July, so its baseline came from timestamps: our first commit landed at 00:33 UTC on March 16, before the next upstream merge that morning. Every README now states its baseline, so the next sync starts with a diff.
- Diff upstream, drop the noise. CI, dependency bumps, lint and docs go. What's left is behavior.
- Port 1:1. Same names, same semantics, our code style, zero runtime dependencies.
- Test against the reference, not against ourselves. Wherever we could, the expected output came from running upstream itself: Go programs for Lip Gloss, Glamour, Huh and Bubbles goldens, the real Go Glow binary for CLI output, Python bm25s for scores, upstream spec-kit for
initoutput. - Version honestly. If upstream's change alters results or removes API, it's a major version on our side too, even when upstream calls it a patch.
III – The Charm Stack
Bubble Tea v2.0.10
Three behavior changes came across:
- No terminal queries without input. With
WithInput(null), the program no longer asks the terminal for its colors, cursor position or capabilities. Nothing reads the replies, so they used to leak into your shell. Our own window-size query goes through the same guard. - The Kitty keyboard stack is restored on exit. The renderer now pushes an entry on first render and on each alt-screen switch, updates it in place when only the flags change, and pops it before switching screens and on close. Two new helpers,
ansi.pushKittyKeyboardandansi.popKittyKeyboard, carry the escape sequences. clearScreen()forces a redraw, even when the view hasn't changed.

Bubbles v2.2.1
The biggest Charm release of the batch:
- A tree component. Expandable, styled trees with upstream's rendering. Lip Gloss's tree renderer comes along as an internal module, and both upstream golden files match byte for byte.
- Text selection in the textarea. Shift+arrows select by character or line, ctrl+shift+arrows by word,
ctrl+gselects all andctrl+shift+ccopies. Typing, paste and delete replace the selection. There's a pointer API (beginSelection,extendSelection,endSelection) for mouse handlers. Copying uses the platform's own clipboard command (pbcopy, PowerShell,wl-copy/xclip/xsel), so it adds no dependencies. - Word keys.
ctrl+left/ctrl+rightin the textarea, andctrl+backspace/ctrl+deletein both text components. - File picker fixes for a picker that has no window size yet.


We skipped one upstream change on purpose. v2.2.0 switched the list component's default Quit key from q/esc to v, with the help text "select". It came in with the tree component and looks like leftover test code. Upstream has an open bug (#1045) and a fix PR (#1046) restoring q/esc, so our port keeps them, with a test pointing at the issue. Parity means matching what upstream intends, not copying a regression they've already reported.
Lip Gloss v2.0.6
Lip Gloss v2.0.6 changed nothing that reaches our port. Its two real changes are in the table component, which we don't have yet. We still used the sync to check our output against upstream, byte for byte.
A Go program against charm.land/lipgloss/v2@v2.0.6 produced 252 wrapping goldens (indents, tabs, CJK, ANSI, hyphens, code blocks × 3 widths × 7 render paths) and 357 hyperlink goldens (every path that writes an OSC 8 link, with both terminators). Matching them turned wrap() and truncate() into direct ports of x/ansi, and made resets and link terminators the exact bytes upstream writes. That's version 1.1.2.


Glamour v2.0.1
Glamour is the odd one out: upstream hadn't released anything since our port. But once we rendered the same markdown through real Go Glamour and through ours, the outputs didn't agree. Horizontal rules repeated, nested lists lost their indentation, and emphasis lost its theme colour.
So the renderer was re-ported from upstream's ansi package: the margin, padding and indent writers, Lip Gloss-style wrapping, table layout, and a parser that splits text exactly where goldmark does. Against a matrix of 10 fixtures × 7 styles × 2 widths rendered by Go, 124 of 140 outputs are now byte-identical. The other 16 differ only in the colours of syntax-highlighted code tokens, because upstream uses chroma and we use highlight.js. The exported writer classes changed shape along the way, so this is 2.0.0.


Huh
Huh v2.0.3 was already in the port. Two fixes had landed on main since: Select now scrolls back to show matches above the cursor when you filter (#804), and forms and groups with no fields no longer break (#808).
Comparing rendered forms against upstream's Go output also brought every built-in theme (Charm, Dracula, Base16, Catppuccin, Base) in line with upstream's theme.go, in both light and dark, and restored the blank line between fields. That's 1.1.1.


IV – Glow v3
Glow v3 moves onto Bubble Tea v2, which our siblings already mirror, so most of the work was the application layer.
$PAGERis parsed like a shell would. Quotes, escapes,$VAR,${VAR:-default},~and brace expansion all work, soPAGER="'/usr/bin/less' -R"does what you'd expect. A malformed value fails with upstream'sunable to parse PAGER commanderror.- Light and dark, detected. The TUI starts dark, asks the terminal for its background color, and rebuilds its styles when the answer comes back.
- The file list finds files the way upstream does. Glow v3 walks directories with gitcha and a fixed set of markdown globs. Both are ported,
.gitignorehandling included, and checked against the Go library on real folders. - A v2
View. The TUI'sview()returns a Bubble Tea v2Viewthat declares alt screen and mouse mode. - v3's pager. The status bar, help, line numbers and error view follow upstream's layout and colors.
showLineNumbersfrom the config file now takes effect in the TUI (from v2.1.2). - Removed:
highPerformancePagerand theGLOW_HIGH_PERFORMANCE_PAGERvariable. Theautostyle now always renders markdown with the dark style.
Rendered through Glamour 2.0.0, the CLI output for a plain-style document is byte-identical to the real Go glow v3.0.0 binary. Those removals, plus the new styles API, make this @oakoliver/glow 2.0.0 (2.0.1 with the file-list work). The one upstream feature we left out is Kitty text-sizing for headers. It's unreleased on upstream's main branch and depends on Go's raw terminal painting.



V – spec-kit 1.0
This was the largest sync by far. Upstream spec-kit went from v0.7.0 to v1.0.12 in 1,291 commits, and its Python source grew from about 16k lines to 62k.
Integrations replace agents. The port now has 41 integrations, up from 28. Seventeen are new: Alquimia, Cline, Command Code, Devin, Docker Agent, Droid, dsh, Firebender, Grok, Hermes, Lingma, Muse, omp, Rovo Dev, Vibe, zcode and Zed. Roo, Windsurf and iFlow are retired, as upstream did. Claude, Codex, cursor-agent and others now install skills.
init changed shape. --integration and --integration-options replace --ai, --ai-commands-dir, --ai-skills and --no-git. The old flags exit with an error. Git is now an opt-in extension. New flags cover the script type (--script sh|ps|py), presets, repeatable extensions and non-interactive runs.
New command families:
specify workflow: a full workflow engine with all of upstream's step types, a catalog and overlaysspecify bundle,artifactandevent runspecify self check/self upgradespecify integration switch,use,upgrade,status,scaffold,search,infoandcatalogupdate,set-priorityandcatalogfor both extensions and presets
Extensions and presets now use extension.yml / preset.yml manifests. That meant writing a YAML parser with no dependencies, and it matches PyYAML on all 82 YAML files in upstream's repository. The templates and scripts are the v1.0.12 versions, including the new converge command and a Python flavor of every script.
Parity was checked by running upstream. Rendered integration output is byte-identical on 29 golden cases, and init produces the same file trees, contents and permissions as upstream Python in 10 configurations. Tests went from 324 to 3,068.
Two things were adapted rather than copied. Self-upgrade checks npm and upgrades with npm, bun, pnpm or yarn instead of PyPI. And custom workflow steps load JavaScript (index.js) rather than a Python __init__.py, so a Python-only step package installs but won't run. That's a product decision we haven't made yet, and the README says so.
Since upstream 1.0 breaks its CLI, this is @oakoliver/specify-cli 2.0.0.


VI – The Python ML Ports
bm25s 0.3.11
Upstream added Korean and Danish stopwords, and progress options on save() and load(). Our port accepts those options as no-ops, like index() already did for showProgress.
Porting the new lists meant checking the old ones, and all 15 are now generated verbatim from upstream's stopwords.py. The default English list is Lucene's 33 words. The default splitter is Unicode-aware, like Python's \w, so "café" stays "café" and Korean text tokenizes.
The whole pipeline, tokenize → index → retrieve, now matches Python bm25s 0.3.11 to five decimal places across all five scoring methods. That comparison is a fixture in the test suite. On a 200,000-document synthetic corpus, indexing runs at about 18M tokens/sec, and tokenize plus index at about 7M tokens/sec end to end.
Because tokens change, results change: an index saved with 1.0.x won't line up with queries tokenized by the new version. So this is 2.0.0.


PageIndex v0.2.19
Upstream moved 218 commits past our baseline. The ported pieces:
- Prompt-injection hardening. Document text is wrapped in
<user_document>tags, known injection phrases are redacted, and the affected prompts get upstream's hardening preamble. - Page-number validation. Page markers the LLM returns are dropped if they weren't in the chunk it saw or fall outside the document.
- Continuing truncated output. When the LLM's reply is cut off, extraction continues in the same conversation, for up to five rounds.
- Upstream's error handling. Auth and not-found errors aren't retried. Exhausted retries throw
LLMRetriesExhaustedinstead of returning the string"Error". Individual failures degrade gracefully. - Tree merge. In the PDF pipeline, a deterministic pass collapses subtrees that aren't worth keeping and records their titles as
keyItems. Markdown trees are built without it, as upstream does. - A separate
summaryModel, with--index-modeland--summary-modelon the CLI. The default index model follows upstream's config.
The tests use a scripted fake LLM injected through setChatClient(), so they need no API key. Upstream's new Flash parser, SDK, chat and MCP surfaces are out of scope. So is LiteLLM routing, which would add a dependency; OpenAI-compatible endpoints already cover other providers.
New default model, errors that throw, and a different PDF output shape: 2.0.0.

VII – go-rod
go-rod hasn't released since v0.116.2, but main has fixes. Two apply to our launcher:
- Browsers on
PATH. Lookup now searchesPATHfor bare names, like Go'sexec.LookPath. That's what makes Chrome on NixOS work (#1136). windowSize()andwindowPosition()launcher methods (#1158).
The other three fixes (header replacement, session-cookie expiry, JS function formatting) belong to @oakoliver/rod and @oakoliver/cdp-proto, which are still to be written. They're listed in the README so they aren't lost.

VIII – Order Matters
The Charm ports depend on each other, so they couldn't be synced independently:
flowchart LR
LG["lipgloss 1.1.2"] --> BB["bubbles 1.2.2"]
BT["bubbletea 1.2.0"] --> BB
LG --> GL["glamour 2.0.0"]
BB --> HUH["huh 1.1.1"]
BB --> GLOW["glow 2.0.1"]
GL --> GLOW
LG --> HUHWe synced them in waves: the leaves first (Lip Gloss and Bubble Tea, alongside the independent Python ports and rod), then Bubbles, then Huh and Glow. Each package was tested against its siblings' fresh local builds, copied into node_modules and restored afterwards, with a check that no file: link leaked into a lockfile.
Publishing follows the same graph: Lip Gloss and Bubble Tea, then Bubbles and Glamour, then Huh and Glow. Their dependency ranges now point at the new versions.
IX – The Accounting
| Before | After | |
|---|---|---|
| Tests across all ten ports | 1,795 | 5,730 |
| Ports recording their upstream baseline | 5 of 10 | 10 of 10 |
| specify-cli integrations | 28 | 41 |
| Major versions | 5 (glamour, glow, bm25s, pageindex, specify-cli) | |
| New runtime dependencies | 0 |
Checking against upstream also turned up a handful of long-standing bugs in the ports, and those are fixed in the same releases. The largest was in @oakoliver/bubbletea: its published build had 105 key codes compiled to undefined, including the media keys, the keypad and F22 and above. That alone is reason to upgrade.
Still open:
- rod and cdp-proto, and the three go-rod fixes waiting for them.
- Lip Gloss tables, so upstream's table changes have somewhere to land.
- SGR merging. Lip Gloss writes each style attribute as its own escape sequence, where upstream combines them into one. Terminals display both the same way, but the bytes differ.
- Python workflow steps in specify-cli, which need a decision rather than a port.
The lesson from April held up: a port isn't finished when it ships, because upstream isn't finished either. Recording the baseline, diffing only behavior, and testing against the real upstream makes each sync a routine chore rather than a rewrite.
– Antonio