esgun is a Go-native, npm-compatible package manager and esbuild bundler: it resolves npm dependencies straight from the registry, manages them (add/remove/update/outdated), audits them against OSV, and bundles your frontend, all in a single static Go binary. No node, no npm, no bun.
go install github.com/oddship/esgun@latest
cd my-frontend
esgun deps && esgun buildpackage.json stays fully standard (dependencies + a conventional scripts
block); the lockfile is standard package-lock.json (lockfileVersion 3, which
npm and Dependabot both accept); the build choreography lives in a tool-named
esgun.config.json next to it (the vite.config / eslint.config
convention).
The JavaScript toolchain is the odd dependency of most Go web projects: a Go binary that ships a frontend still needs node/npm/bun installed everywhere it's built, for what is usually a handful of leaf dependencies copied verbatim into the binary. esgun replaces that with Go: the registry protocol, semver resolution, lockfile management, security auditing, and bundling all run as Go (esbuild as a library), so Go 1.26 is the only toolchain requirement.
As a deliberate security posture, esgun never runs lifecycle scripts:
nothing in the dependency tree ever executes during install, and it verifies
every tarball's SHA-512 against the registry's dist.integrity before
anything touches disk.
Three ways, depending on how you want to consume it:
1. PATH binary (for ad-hoc use anywhere):
go install github.com/oddship/esgun@latest2. Run without installing:
go run github.com/oddship/esgun@latest <cmd>3. Go tool dependency (recommended for projects, Go 1.24+): pin esgun in the
project's go.mod so the version is locked with the rest of the toolchain and
contributors get it automatically:
go get tool github.com/oddship/esgun@latestThis adds tool github.com/oddship/esgun to go.mod (with the resolved
version in require + go.sum). The project then invokes the pinned build
from anywhere:
go tool esgun depsBumping the tool is a normal go get tool github.com/oddship/esgun@vX.Y.Z,
same as any other Go dependency. The first go tool esgun run compiles it;
subsequent runs use the build cache.
esgun deps [--dir DIR] [--frozen] [-u] # install from lockfile
esgun ci [--dir DIR] # frozen install (fails on lockfile drift)
esgun install [--dir DIR] # alias of deps
esgun add <pkg>[@range] [--dev] [--dir DIR] # add/upgrade a dependency
esgun remove <pkg> [--dir DIR] # remove a dependency
esgun update [pkg] [--dir DIR] # re-resolve within declared ranges
esgun outdated [--dir DIR] # newer versions available
esgun audit [--dir DIR] # OSV vulnerability report (exit 1 on findings)
esgun build [--dir DIR] # bundle + assemble the output
esgun watch [--dir DIR] # rebuild on change
Flags: --dir DIR (default "."), --registry URL (or ESGUN_REGISTRY), --frozen, --dev, -u
mkdir my-frontend && cd my-frontend
esgun add dayjs # writes "dayjs": "^1.11.x" to package.json, installs
esgun add @codemirror/state --dev
esgun build # needs an esgun.config.json (see below)- Reads
package.jsonas a manifest (name/version list only; nothing executes it). - Resolves the full dependency graph against the registry (abbreviated packuments, exactly like npm itself), honoring npm-style semver ranges (
^,~,>=,||, …). - Reuses the existing
package-lock.jsonwhen a pinned version still satisfies its range (installs are reproducible).-ure-resolves everything. - Verifies every tarball's SHA-512 against the registry's
dist.integritybefore extracting. - Writes a
package-lock.json(lockfileVersion 3) that npm and Dependabot can consume (npm ci --dry-runpasses on it). - Extracts packages into
vendor/node_modules/<name>; nonode_modulesdirectory, no lifecycle scripts.
add/remove/update edit package.json (dependency maps stay alphabetically
sorted; every other field is preserved byte-for-byte) and then re-resolve the
graph, rewrite the lockfile, and refresh the vendor tree (stale packages
pruned):
esgun add dayjs: resolve latest stable (via the registrydist-tags), save^<version>, install.esgun add pkg@1.2.3saves the exact range;esgun add pkg@~0.7saves it verbatim;esgun add pkg@nextresolves a dist-tag.esgun remove dayjs: drop from package.json, lockfile, and vendor tree.esgun update: re-resolve everything within declared ranges (npm update semantics; package.json unchanged).esgun update dayjs: same, for one package.
devDependencies are resolved too (marked "dev": true in the lockfile);
unsatisfied peer dependencies and unavailable optional dependencies degrade to
warnings instead of errors.
- Bundles the configured
entrypoints with esbuild as a Go library (github.com/evanw/esbuild): ESM output, code splitting intochunks/[name]-[hash].js, minified, browser target. Package imports resolve through theresolvedirs (e.g.vendor/node_modules) via esbuildNodePaths. - The assembly choreography is project data, not tool code: an
esgun.config.jsonnext topackage.jsondeclares the output root, entry set (explicit paths or*globs), extra resolve roots, verbatim file/directory copies, curated tree copies, and watch dirs.
package.json stays fully standard: deps plus a conventional scripts block
("build": "esgun build", "watch": "esgun watch").
Rebuilds on any change under the configured watch dirs (default src,
100 ms debounce). Uses inotify via fsnotify
plus a 300 ms mtime poll as a fallback, because host-mounted workspaces may not
deliver inotify events for host-side editor writes.
esgun audit queries the free OSV database for every
resolved version in the lockfile and exits 1 if any vulnerabilities are found;
drop-in npm audit behavior for CI, without a node runtime.
MVP: single-package layout, flat resolution (no workspaces/monorepos),
no lifecycle scripts, no private-registry auth, no publish command. All by design.
Known follow-ups: unit tests for the resolver, go mod tidy normalization on a
Go 1.26 toolchain, and review of semver edge cases beyond
Masterminds/semver's coverage.