Vale via npm, without an install script
The Taskless CLI shells out to compiled binaries. ast-grep does the static analysis on code, and as of 0.11 Vale does the same for prose and docs. Neither is written in JavaScript, the CLI ships on npm, and the canonical way to run it is npx @taskless/cli, which is an ephemeral install on a machine we know nothing about.
So the engineering problem is getting a platform-correct binary onto disk, reliably, without asking anyone to install anything. That problem got harder this year.
The install step stopped being automatic
npm v12 defaults allowScripts to off. Dependency lifecycle scripts, preinstall and install and postinstall, plus implicit node-gyp builds, no longer run unless a package has been explicitly allowed. GitHub's reasoning is that install-time scripts are the single largest code-execution surface in the npm ecosystem, because npm install runs scripts from every transitive dependency and one compromised package anywhere in the tree gets arbitrary code on a developer machine or a CI runner. After the year npm has had, that's hard to argue with.
The opt-in path is npm approve-scripts, which produces an allowlist you commit to your package.json.
That doesn't work if your package is invoked directly. npx @taskless/cli is an ephemeral install: there's no project package.json to hold the allowlist, nobody to review a prompt, and no committed state surviving to the next run. A postinstall-based binary delivery isn't degraded there, it fails, and it fails by design.
Which lands squarely on a tool like this one. Taskless is only useful if it can reach the Rust binaries it drives, ast-grep first and now Vale, on whatever machine the user happens to be on. An install step that used to be automatic is now something a user opts into, per package, and records. Any packaging that needs one picked up a manual step it didn't have last year.
What ast-grep worked out
For ast-grep we didn't have to solve this. The ast-grep team already had, and their approach is good.
The pattern is per-platform packages. Rather than one package that fetches a binary at install time, you publish one package per platform, each containing only the binary for that platform. @ast-grep/cli-darwin-arm64 holds the macOS ARM build, @ast-grep/cli-linux-x64 holds the Linux x64 build, and so on.
The consuming package declares all of them in optionalDependencies, and each platform package declares os and cpu in its own manifest. npm reads those fields, installs the one that matches the host, and skips the rest. A user on an M-series Mac downloads one binary. Our lockfile has @ast-grep+cli-darwin-arm64 in it and nothing else.
Following the lead of packages like @ast-grep/cli-darwin-arm64, the binary inside the tarball has its executable bit preserved. That removes the need to chmod anything after the install, which is one of the most common reasons for needing lifecycle scripts at all. By installing only one platform binary per engine, optionalDependencies also keeps the install weight low. The ast-grep binary does weigh in at 46MB, but for a node dependency that's negligible.
What we dropped, and the design that came out of it
ast-grep still runs a postinstall. It hardlinks the platform binary into the main package so that bin: {sg, ast-grep} works, which is what you want when a human types sg at a terminal.
That script broke for us. Under pnpm dlx, strict dependency isolation means the hardlink doesn't land, and what you get where the executable should be is a placeholder text file. Not a missing file, which would be a clean failure. A file that exists, has the right name, and isn't a binary.
Our resolver already carried a workaround comment about it before Vale was on the roadmap. findSgBinary() resolves @ast-grep/cli-<platform>/package.json through createRequire, execs the binary sitting next to it, and falls back to sg on PATH. It goes around the hardlink entirely.
And this led to the design for Vale inside of Taskless: if you exec by path, you never need the hardlink, and if you never need the hardlink, you don't need a lifecycle script at all.
So the Vale packages have no bin, no code, and no scripts. Each one contains the binary, a package.json declaring os and cpu, a README, and Vale's MIT license. That's the entire package.
Packages with no scripts skipped this year's transition entirely. That wasn't foresight. We were routing around pnpm dlx and the ecosystem happened to arrive at the same place. The upside is that no package-manager policy can stop the binary from being there: there's no script to block, no step to fail, and nothing to allowlist.
Why not the Vale package that already exists
There's one Vale package on npm. It's third-party, maintained by someone outside the Vale project, and it ships an empty tarball plus a postinstall that downloads the release archive.
Unfortunately, the postinstall problem above prevents it from being used in an ephemeral environment. A postinstall executes during a consumer's install, subject to whatever that consumer's package manager has decided about scripts. pnpm 10 already blocked dependency build scripts without an onlyBuiltDependencies allowlist, and npm 12 now defaults to the same posture for everybody. When that block fires you get no binary and no error, which is the worst combination available: the install succeeds and the tool is broken at runtime.
There are two further problems, neither aesthetic. A download at install time has no integrity guarantee, since npm verifies what it serves you and can't verify what a script fetches from somewhere else afterward. And anything reaching a third-party host at install time fails behind a corporate proxy or an offline mirror, which are exactly the environments where a team most wants a deterministic install.
The same objection would apply if the Vale project itself published that package. This isn't about who maintains it.
We also considered downloading at runtime into node_modules, and dropped it quickly. The canonical invocation is npx, so the install is ephemeral and the binary would be re-fetched on essentially every run. Writing into node_modules is also unsafe where pnpm's content-addressable store is hardlinked into unrelated projects on the same machine.
Six packages
Vale publishes six binary assets per release, so the matrix is six packages:
Package os cpu--------------------------- ------ -----@taskless/vale-darwin-arm64 darwin arm64@taskless/vale-darwin-x64 darwin x64@taskless/vale-linux-arm64 linux arm64@taskless/vale-linux-x64 linux x64@taskless/vale-win32-arm64 win32 arm64@taskless/vale-win32-x64 win32 x64Six, where ast-grep ships seven. The seventh is win32-ia32, a 32-bit x86 Windows build for hosts that can't run 64-bit code, which by now means old hardware and a few embedded images. Vale doesn't publish one, so the matrix follows what upstream actually releases rather than mirroring the pattern we copied it from.
The packages live in the Taskless repo and publish to the Taskless scope, next to the CLI that consumes them. npm scope ownership and the trusted-publisher configuration stay in one place, and version coordination doesn't cross a repo boundary.
A platform outside that matrix falls back to Vale on PATH, and if it isn't there the resolver reports the engine unavailable rather than failing the run. That's a quiet degradation, so the matrix has to cover the realistic deployment surface rather than the minimum one.
The strangest version number
Every platform package is versioned <valeVersion>-<yyyymmddhhmmss>. Vale support starts at 3.20.0, so the first package published was 3.20.0-20260907164938. A plain upstream version like 3.20.0 is never published on its own.
Every published version being a prerelease means ^3.20.0 can't resolve to any of them, because semver only lets a prerelease satisfy a range that names the same major.minor.patch with a prerelease of its own. Exact pinning stops being a convention we ask for and becomes something the resolver enforces.
It buys two other things. The upstream Vale version stays legible at a glance, since it's the leading component and you can read it without opening the package. And a packaging fix against the same upstream release gets a fresh timestamp instead of needing a version number it doesn't own. Mirroring upstream exactly would leave nowhere to go, and the obvious escape hatch is a trap: 3.20.0-taskless.1 is a prerelease and sorts before 3.20.0.
The CLI then declares each platform package in optionalDependencies at a literal exact version. Starting with Vale 3.20.0 that looked like this:
"optionalDependencies": { "@taskless/vale-darwin-arm64": "3.20.0-20260907164938", "@taskless/vale-darwin-x64": "3.20.0-20260907164938", "@taskless/vale-linux-arm64": "3.20.0-20260907164938", "@taskless/vale-linux-x64": "3.20.0-20260907164938", "@taskless/vale-win32-arm64": "3.20.0-20260907164938", "@taskless/vale-win32-x64": "3.20.0-20260907164938"}optionalDependencies specifically. A devDependency isn't installed for anyone consuming @taskless/cli, so the binary would reach contributors to our repo and nobody else. optional is also what lets an unsupported platform install the CLI cleanly with no platform package present.
And a literal version rather than workspace:*. The platform packages are versioned by a workflow that follows upstream Vale's cadence, so a workspace protocol would silently re-point the CLI at whatever got stamped most recently. A literal pin means a newly published platform package is inert until someone deliberately bumps it. That property is what makes the publishing automation below safe to run unattended.
Fetch at release time, verify against a reviewed checksum
The binaries aren't in our git history. Six platforms at 10 to 20MB each would sit there permanently, and the package directories are source-only with the binary gitignored. The release pipeline puts it in place before packing.
This causes problems for repo inspection though: a published tarball isn't reproducible from a plain git clone. Reproducing one means re-running the fetch against the pinned upstream release. What makes that reproduction verifiable is the checksums.
The SHA256 of each platform's upstream release asset is committed to the repo and reviewed like any other change. The pipeline verifies every fetched binary against it and refuses a mismatch, and that verification happens in a step with no credentials, so the credentialed job only ever handles bytes that already matched a reviewed digest.
This is the part we care most about. Our release workflow's trust boundary is "what can merge to main." Fetching an unverified third-party binary inside a job holding a publishing identity would quietly move that boundary to "whatever the upstream host served today."
Which creates a tension, because we also want this automated. Nobody should have to notice a Vale release. The workflow resolves it in two phases:
- Detect, unattended. A scheduled job watches upstream. When Vale publishes a release we haven't packaged, it opens a pull request updating the pinned version and the committed checksums. It publishes nothing.
- Publish, on merge. Merging that PR triggers fetch, verify, stamp, pack, publish. The checksums it verifies against are the ones a human just reviewed.
A single-phase "detect and publish" workflow can't have both halves. It would either skip verification or verify against a digest it discovered itself, and verifying a download against a checksum from the same source verifies nothing.
Two independent gates, then. Review to publish the package, and a separate deliberate bump of the CLI's pin to adopt it.
What we took on
Packaging someone else's binary makes us a redistributor, and that's an ongoing obligation rather than a one-time task. Vale is MIT, and each package ships the upstream license and attribution. More importantly, an upstream security fix now requires us to publish, which means tracking Vale releases is part of running this thing.
The detect workflow exists so that obligation doesn't depend on anyone remembering.
Vale support ships in the Taskless CLI 0.11 line.