Skip to content

feat: distribute fmtkit as a single self-contained binary (Homebrew + GitHub Releases)#48

Open
gocanto wants to merge 4 commits into
mainfrom
feat/single-binary-distribution
Open

feat: distribute fmtkit as a single self-contained binary (Homebrew + GitHub Releases)#48
gocanto wants to merge 4 commits into
mainfrom
feat/single-binary-distribution

Conversation

@gocanto

@gocanto gocanto commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Delivers the distribution goal that #46's root-module collapse was the prerequisite for: one self-contained fmtkit binary 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

fmtkit (Go binary, per platform, ~90-100MB / ~35-49MB tar.gz)
├─ go:embed fmtkit-ts-sidecar   bun-compiled bundle of the devx TS pipeline
├─ go:embed oxc-parser.node     napi bindings, loaded through
├─ go:embed oxfmt.node          NAPI_RS_NATIVE_LIBRARY_PATH
├─ go:embed oxlint.node
└─ go:embed .oxfmtrc.json / .oxlintrc.json

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 existing internal/cli runner, and file collection reuses internal/sourcefiles instead of the fmtkit-sources child 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 gitignored infra/bin/<os>_<arch>/ directories; versions pinned by packages/devx/package.json.
  • embedded_sidecar_*.go (module root, where go:embed can reach infra/bin/) — per-platform embeds behind the fmtkit_sidecar build 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 of infra/bin/fmtkit: sectioned/colorized progress, live-streamed prettified tool logs plus the condensed summaries; --quiet restores summary-only output.
  • packages/driver/cmd/fmtkit — the distributed CLI: format, format-all, ts, lint, go <check|format|sources|...>, check, version, help. format/format-all take --ts (TS/Vue format + lint only) and --go (Go formatting only); no flags runs everything.
  • .goreleaser.yaml + publish-release.yml binaries job — archives + checksums attached to the existing release, Homebrew cask pushed to oullin/homebrew-fmtkit.
  • tests.yml binary job — 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 swap Dockerfile.full onto the Go orchestrator.

Action needed before the first release

Create a fine-grained PAT with Contents: read/write on oullin/homebrew-fmtkit and save it as the HOMEBREW_TAP_TOKEN repo secret — GITHUB_TOKEN cannot push cross-repo. Until it exists, the binaries job will fail at the cask-push step.

Validation

  • go test ./..., golangci-lint run (driver), pnpm test + pnpm typecheck (devx) all green.
  • goreleaser release --snapshot --clean builds 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.
  • Dogfood: fmtkit format . over this repo itself produced the expected house-style formatting of the new files.

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +237 to +246
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)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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)
		}
	}

Comment on lines +30 to +77
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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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" +

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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.

Suggested change
"printf '%s\\n' \"$*\" >> " + logFile + "\n" +
"printf '%s\\n' \"$*\" >> \"" + logFile + "\"\n" +

gocanto added 3 commits July 15, 2026 17:48
- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant