feat: distribute fmtkit as a single self-contained binary (Homebrew + GitHub Releases)#48
feat: distribute fmtkit as a single self-contained binary (Homebrew + GitHub Releases)#48gocanto wants to merge 4 commits into
Conversation
Adds the missing distribution piece that #46's module collapse set up: one `fmtkit` binary per platform, published on GitHub Releases and installable through the oullin/homebrew-fmtkit tap, with zero runtime dependencies (no Node.js, no Docker). - packages/devx/scripts/sidecar.ts: bun-compiled multiplexer that runs the TS pipeline, oxfmt, and oxlint from one executable, loading the napi bindings via NAPI_RS_NATIVE_LIBRARY_PATH. - infra/scripts/release/stage-ts-assets.sh: builds the sidecar per platform and fetches the oxc-parser/oxfmt/oxlint bindings, pinned by packages/devx/package.json. - packages/driver/internal/tsruntime: embeds the staged assets behind the fmtkit_sidecar build tag and extracts them to the user cache on first run. - packages/driver/internal/orchestrator: Go port of the infra/bin/fmtkit pipeline with live-streamed, prettified tool logs plus the existing condensed summaries (--quiet restores summary-only output). - packages/driver/cmd/fmtkit: the distributed entry point; subsumes the fmtkit-go surface under `fmtkit go`. - .goreleaser.yaml + publish-release.yml `binaries` job: cross-platform tar.gz archives, checksums, and the Homebrew cask push (needs the HOMEBREW_TAP_TOKEN secret). - tests.yml `binary` job: goreleaser config check plus an end-to-end smoke test of the compiled binary.
There was a problem hiding this comment.
Code Review
This pull request introduces a self-contained fmtkit binary and release pipeline, embedding the TS toolchain (including the Bun-compiled sidecar and napi bindings) directly into the Go binary. It adds Go orchestrator logic to manage the pipeline execution and extract embedded assets on first run. The review feedback highlights opportunities to optimize memory usage by streaming embedded assets during hashing, reduce redundant string splitting in log summarization, and improve test robustness by quoting paths in shell scripts.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| for _, name := range names { | ||
| contents, err := fs.ReadFile(assets, name) | ||
|
|
||
| if err != nil { | ||
| return "", fmt.Errorf("read embedded %s: %w", name, err) | ||
| } | ||
|
|
||
| hash.Write([]byte(name)) | ||
| hash.Write(contents) | ||
| } |
There was a problem hiding this comment.
Reading large embedded assets (such as the Bun-compiled sidecar binary, which is 30MB+) entirely into memory via fs.ReadFile can cause significant memory spikes and GC pressure, especially in memory-constrained environments like CI runners or small containers. Stream the file contents directly into the SHA256 hash using io.Copy instead.
for _, name := range names {
hash.Write([]byte(name))
file, err := assets.Open(name)
if err != nil {
return "", fmt.Errorf("open embedded %s: %w", name, err)
}
_, err = io.Copy(hash, file)
_ = file.Close()
if err != nil {
return "", fmt.Errorf("read embedded %s: %w", name, err)
}
}| func lastWithPrefix(log, prefix string) string { | ||
| var match string | ||
|
|
||
| for _, line := range lines(log) { | ||
| if strings.HasPrefix(line, prefix) { | ||
| match = line | ||
| } | ||
| } | ||
|
|
||
| return match | ||
| } | ||
|
|
||
| func summarizeTSFormat(log string, l *logger) { | ||
| missing := 0 | ||
|
|
||
| for _, line := range lines(log) { | ||
| if strings.HasPrefix(line, sourcesMissingPrefix) { | ||
| missing++ | ||
| } | ||
| } | ||
|
|
||
| if line := lastWithPrefix(log, blankLinesPrefix); line != "" { | ||
| l.detail("blank-lines", strings.TrimPrefix(line, "[blank-lines] ")) | ||
| } | ||
|
|
||
| if missing > 0 { | ||
| l.detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing)) | ||
| } | ||
|
|
||
| if line := lastWithPrefix(log, oxfmtFinishedPrefix); line != "" { | ||
| l.detail("oxfmt", line) | ||
| } | ||
|
|
||
| if line := lastWithPrefix(log, fluentChainsPrefix); line != "" { | ||
| l.detail("fluent", strings.TrimPrefix(line, "[fluent-chains] ")) | ||
| } | ||
|
|
||
| if line := lastWithPrefix(log, validateSyntaxPrefix); line != "" { | ||
| l.detail("validated", strings.TrimPrefix(line, "[validate-syntax] ")) | ||
| } | ||
| } | ||
|
|
||
| func summarizeTSLint(log string, l *logger) { | ||
| if lastWithPrefix(log, lintNothingToLintLine) != "" { | ||
| l.detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] ")) | ||
|
|
||
| return | ||
| } |
There was a problem hiding this comment.
The summarizeTSFormat and summarizeTSLint functions call lastWithPrefix multiple times, each of which splits the entire log string by \n repeatedly. This causes redundant string splitting and unnecessary memory allocations. Refactor lastWithPrefix to accept pre-split lines ([]string) and split the log only once per summarizer.
func lastWithPrefix(lines []string, prefix string) string {
var match string
for _, line := range lines {
if strings.HasPrefix(line, prefix) {
match = line
}
}
return match
}
func summarizeTSFormat(log string, l *logger) {
logLines := lines(log)
missing := 0
for _, line := range logLines {
if strings.HasPrefix(line, sourcesMissingPrefix) {
missing++
}
}
if line := lastWithPrefix(logLines, blankLinesPrefix); line != "" {
l.detail("blank-lines", strings.TrimPrefix(line, "[blank-lines] "))
}
if missing > 0 {
l.detail("skipped", fmt.Sprintf("%d missing tracked file(s)", missing))
}
if line := lastWithPrefix(logLines, oxfmtFinishedPrefix); line != "" {
l.detail("oxfmt", line)
}
if line := lastWithPrefix(logLines, fluentChainsPrefix); line != "" {
l.detail("fluent", strings.TrimPrefix(line, "[fluent-chains] "))
}
if line := lastWithPrefix(logLines, validateSyntaxPrefix); line != "" {
l.detail("validated", strings.TrimPrefix(line, "[validate-syntax] "))
}
}
func summarizeTSLint(log string, l *logger) {
logLines := lines(log)
if lastWithPrefix(logLines, lintNothingToLintLine) != "" {
l.detail("oxlint", strings.TrimPrefix(lintNothingToLintLine, "[lint] "))
return
}|
|
||
| script := "#!/usr/bin/env bash\n" + | ||
| "set -euo pipefail\n" + | ||
| "printf '%s\\n' \"$*\" >> " + logFile + "\n" + |
There was a problem hiding this comment.
The temporary logFile path is appended to the shell script without quotes. If the temporary directory path contains spaces or special characters, the shell script execution will fail. Quote the logFile path in the shell script redirect for robustness.
| "printf '%s\\n' \"$*\" >> " + logFile + "\n" + | |
| "printf '%s\\n' \"$*\" >> \"" + logFile + "\"\n" + |
- orchestrator/cmd tests assert plain output; CI task runners export FORCE_COLOR, which injected ANSI codes into the captured buffers. - setup-node v5 auto-detects pnpm from pnpm-lock.yaml for caching, but the binary jobs only use npm.
…tep flags - move per-platform go:embed files to the module root so the staged bun sidecar + oxc binaries live in gitignored infra/bin/<os>_<arch>/ directories instead of packages/driver/internal/tsruntime/embedded - drop the now-empty tsruntime/embedded package; tsruntime reads the toolchain via fmtkit.SidecarAssets() - add --ts / --go step-selection flags to format and format-all (no flags runs everything; unknown flags exit 2 with usage) - cover project-local .oxlintrc.json overriding the bundled config
Summary
Delivers the distribution goal that #46's root-module collapse was the prerequisite for: one self-contained
fmtkitbinary per platform, downloadable from GitHub Releases and installable via Homebrew (brew tap oullin/fmtkit && brew install --cask fmtkit) — no Node.js, no Docker, no npm at runtime.How it works
First run extracts the toolchain to the user cache (
~/Library/Caches/fmtkit/<version>/$XDG_CACHE_HOME/fmtkit/<version>); after that everything is spawned from there. Go formatting runs in-process via the existinginternal/clirunner, and file collection reusesinternal/sourcefilesinstead of thefmtkit-sourceschild process.packages/devx/scripts/sidecar.ts— multiplexes pipeline/oxfmt/oxlint through one Bun executable so the runtime ships once.infra/scripts/release/stage-ts-assets.sh— cross-compiles the sidecar (bun build --compile --target ...) and fetches the platform napi bindings into gitignoredinfra/bin/<os>_<arch>/directories; versions pinned bypackages/devx/package.json.embedded_sidecar_*.go(module root, where go:embed can reachinfra/bin/) — per-platform embeds behind thefmtkit_sidecarbuild tag (dev builds stay light).packages/driver/internal/tsruntime— cache extraction with atomic rename, tool spawning with the existing env-override contract (FMTKIT_SUPPORT_DIR,OXFMT_BIN,OXLINT_BIN, ...); project-local.oxfmtrc.*/.oxlintrc*files still win over the bundled configs.packages/driver/internal/orchestrator— Go port ofinfra/bin/fmtkit: sectioned/colorized progress, live-streamed prettified tool logs plus the condensed summaries;--quietrestores summary-only output.packages/driver/cmd/fmtkit— the distributed CLI:format,format-all,ts,lint,go <check|format|sources|...>,check,version,help.format/format-alltake--ts(TS/Vue format + lint only) and--go(Go formatting only); no flags runs everything..goreleaser.yaml+publish-release.ymlbinariesjob — archives + checksums attached to the existing release, Homebrew cask pushed tooullin/homebrew-fmtkit.tests.ymlbinaryjob —goreleaser check+ an end-to-end smoke test of the compiled binary on a scratch project.Docker images and the
infra/bin/*entrypoints are untouched; a follow-up can swapDockerfile.fullonto the Go orchestrator.Action needed before the first release
Create a fine-grained PAT with Contents: read/write on
oullin/homebrew-fmtkitand save it as theHOMEBREW_TAP_TOKENrepo secret —GITHUB_TOKENcannot push cross-repo. Until it exists, thebinariesjob will fail at the cask-push step.Validation
go test ./...,golangci-lint run(driver),pnpm test+pnpm typecheck(devx) all green.goreleaser release --snapshot --cleanbuilds all four platforms; the darwin-arm64 dist binary formats a scratch TS+Go project end to end, extracting to the versioned cache../infra/scripts/tasks/test-binary-smoke.sh(also wired into CI) asserts exact formatted output for TS and Go fixtures.fmtkit format .over this repo itself produced the expected house-style formatting of the new files.