← BACK TO ENGINEERING
Runtime 13 min read

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.

A 'Ten ports. One pass.' page captured in a desktop browser, a tablet and a phone by real Chrome instances driven through our browser-launcher and CDP ports, showing 5,730 tests and every port's new version


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.

  1. 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.
  2. Diff upstream, drop the noise. CI, dependency bumps, lint and docs go. What's left is behavior.
  3. Port 1:1. Same names, same semantics, our code style, zero runtime dependencies.
  4. 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 init output.
  5. 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.pushKittyKeyboard and ansi.popKittyKeyboard, carry the escape sequences.
  • clearScreen() forces a redraw, even when the view hasn't changed.

A Bubble Tea dashboard built from our ports: each Charm port's upstream tag, version and sync-branch commit, its test suite running live (2,409 passing, 0 failing), tests per port before and after the sync, lines changed per repo from git, and an event log of real commits and test runs

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+g selects all and ctrl+shift+c copies. 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+right in the textarea, and ctrl+backspace/ctrl+delete in both text components.
  • File picker fixes for a picker that has no window size yet.

An IDE-style terminal app built on our Bubbles port: the new tree component browsing the real bubbletea, bubbles and lipgloss repos, with the tree component's own source open in the editor

The same app with a five-line block selected in the textarea using the new keyboard selection, and the status bar reading '5 lines, 241 chars selected'

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.

The same indented Go function wrapped at 40 and 30 columns by lipgloss 1.1.0, lipgloss 1.1.1 and real Go Lip Gloss v2.0.6 side by side; the new port is byte-identical to Go, indentation included

A terminal poster rendered by @oakoliver/lipgloss 1.1.2: a gradient LIPGLOSS banner, four border styles, a dialog placed on a patterned field and layered windows with drop shadows, with a live badge showing 609 of 609 Go goldens passing

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.

The same markdown report rendered by @oakoliver/glamour 2.0.0 in the dracula and tokyo-night styles side by side, with a table, nested lists, task items, a blockquote and a single horizontal rule

A TypeScript block and the equivalent Go block, syntax-highlighted by @oakoliver/glamour in the tokyo-night and dark styles

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.

A 'Publish a port' form built with @oakoliver/huh in the Charm theme: a select of all ten ports, a version input, a multi-select of targets and a confirm, captured mid-interaction

A huh Select of 47 articles filtered by 'bun': the cursor started near the bottom of the list, and every match, including the ones above it, is shown from the top


IV – Glow v3

Glow v3 moves onto Bubble Tea v2, which our siblings already mirror, so most of the work was the application layer.

  • $PAGER is parsed like a shell would. Quotes, escapes, $VAR, ${VAR:-default}, ~ and brace expansion all work, so PAGER="'/usr/bin/less' -R" does what you'd expect. A malformed value fails with upstream's unable to parse PAGER command error.
  • 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, .gitignore handling included, and checked against the Go library on real folders.
  • A v2 View. The TUI's view() returns a Bubble Tea v2 View that declares alt screen and mouse mode.
  • v3's pager. The status bar, help, line numbers and error view follow upstream's layout and colors. showLineNumbers from the config file now takes effect in the TUI (from v2.1.2).
  • Removed: highPerformancePager and the GLOW_HIGH_PERFORMANCE_PAGER variable. The auto style 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.

Glow's file list running on our port over the engineering articles folder: 47 documents, filtered to the nine 'porting' articles

Glow's v3 pager with line numbers reading a real article, showing a syntax-highlighted TypeScript loop and the Glow status bar

Glow in CLI mode with the Dracula style, rendering two benchmark tables comparing Python bm25s with our TypeScript port


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 overlays
  • specify bundle, artifact and event run
  • specify self check / self upgrade
  • specify integration switch, use, upgrade, status, scaffold, search, info and catalog
  • update, set-priority and catalog for 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.

specify version printing the gradient SPECIFY banner and an info panel with CLI Version 2.0.0 and Upstream Parity spec-kit 1.0.12

specify integration list in a scratch project: all 41 coding-agent integrations, with Claude Code, Codex CLI and Gemini CLI installed side by side


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.

bm25s indexing 3,195 paragraphs from 47 engineering articles in 15.3 ms and a 200,000-document corpus at 18.4M tokens/sec, with ranked results, score bars and highlighted matches for three queries

Queries in Korean, Danish, French, Chinese, German, Russian, Spanish and English, each showing its tokens with stopwords struck through and the top hit, with our score beside Python bm25s 0.3.11's; all 136 scores match to within 1.22e-7

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 LLMRetriesExhausted instead 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-model and --summary-model on 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.

PageIndex building a tree index of a 405-line article in 0.67 ms with no LLM calls: node ids, heading levels, line ranges, approximate token counts, a map of where each section sits in the file, and the nodes a mergeTree pass over 30-line pages folds into keyItems


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 searches PATH for bare names, like Go's exec.LookPath. That's what makes Chrome on NixOS work (#1136).
  • windowSize() and windowPosition() 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.

A live headless Chrome session driven by our go-rod ports: Chrome found on PATH, launched with windowSize and windowPosition, network events with status and timings, typed input, DOM queries and load-timing bars, styled with our Lip Gloss port


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 --> HUH

We 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

"Simplicity is the ultimate sophistication."