DRAFT
The SPFx Install Script Grows Up

Back in December I introduced a Node.js script that automates SPFx environment setup—pick a version or an alias like SPO or SP2019, and it figures out the right Node.js, Yeoman, generator, and task runner versions by querying the npm registry directly instead of relying on a hardcoded matrix.
That script has been in daily use across real client work ever since, which turned out to be the best possible test suite. It broke in ways I didn’t anticipate, got rebuilt in places I thought were already solid, and picked up a handful of features I only discovered I needed once I was living in it.
This is the story of what ten months of real use taught me—and, starting in August, what building it alongside Claude taught me too.
From Shell Scripts to One Node.js Tool
The original post undersold the prehistory a bit, so here’s the fuller timeline. This thing started life in April 2023 as install-spfx.sh, built on nvm, and I hand-updated its compatibility logic every time Microsoft shipped a new SPFx release—1.18.0, 1.19.0, 1.20, 1.21.x. In May 2025 I ported it to PowerShell using nvs. By November 2025 both versions had moved to fnm, and on November 27 I finally rewrote the whole thing as a single cross-platform install-spfx.js, deleting the shell and PowerShell versions for good. Everything since then has been iteration on that one file.
Teaching the Script to Reuse Environments
The December version installed a fresh Node.js environment every time you asked for a SPFx version it hadn’t seen yet, even if a perfectly good Node install already satisfied the requirement. Fine for an occasional install, wasteful if you’re bootstrapping three or four client environments a week.
In August I changed the rule: without -full, the script now reuses the highest already-installed Node.js version that satisfies the engine range, instead of installing a new one for every SPFx release. That required deciding when it’s safe to share a Node install between SPFx versions and when it isn’t. The rule I landed on: an install that carries the scaffolding tools (Yeoman + generator) must stay dedicated to exactly one SPFx alias, because different SPFx versions can need different Yeoman versions. An install that only carries a task runner can be shared freely, since gulp-cli and Heft are less version-sensitive. The script figures out which situation it’s in by inspecting the global node_modules on disk, and if you run -full against a Node that’s currently shared, it re-points that alias to its own dedicated install rather than contaminating a shared one.
The Engine-Range Parser: A Case Study in npm Semver Pain
This is where most of the real bugs lived, and where Claude came in starting August 11. I’d write “this handles >=, <, ^, ~, and ||” in the original post with some confidence—turns out confidence and correctness are different things.
The worst one: || alternatives were being ANDed together instead of ORed. Yeoman’s real engines.node field reads something like >=18.17.0 <19.0.0 || >=20.5.0—satisfy the Node 18 bracket or the Node 20-plus bracket. ANDed together those two clauses contradict each other (nothing is both below 19 and at or above 20.5), so the check could never pass, on any Node version, and the script silently fell back to an old pinned Yeoman version on every single run. I’d been shipping the wrong tool version the entire time and had no idea, because the fallback didn’t look like a failure—it looked like a normal, if slightly outdated, choice.
A few more in the same vein, all found by throwing real version strings at it instead of the handful I’d tested with originally:
- Partial versions like
>=22.13or^18(no patch, sometimes no minor) matched nothing and were incorrectly treated as compatible—the opposite of fail-safe. - A stray space in the published SPFx range
< 23.0.0made every Node 22 install look incompatible, because the parser choked on the space after<. - Prerelease versions were getting selected for yo and gulp-cli when they shouldn’t have been eligible at all.
- Wildcard and upper-only ranges (
~18, a bare18,*) needed to follow actual npm semver rules, not my approximation of them. This one was live and biting: pnpm’s own>=18.*range was silently selecting pnpm 11 on a Node 22 install.
There had also quietly been two different range-parsing implementations in the file that disagreed with each other on edge cases. Those got unified into one determineCompatibleVersion path, and by late September there was finally a node:test suite covering the parser and the Next alias resolution—zero dependencies, just assertions against the version strings that had actually burned me.
Never Trust the PATH Node
fnm’s fnm use doesn’t work reliably in non-interactive shells, which meant global tool installs were sometimes landing in whatever Node happened to be active in the calling shell—not the Node version the script had just installed for that SPFx alias. Every global install now goes through the target version’s own npm explicitly, and on Linux/macOS that meant running npm-cli.js directly with the target Node binary, because npm’s own launcher script (#!/usr/bin/env node) was resolving to the PATH Node regardless of which Node you’d pointed it at. The script also stopped claiming an environment was “activated” when activation had actually failed, and stopped assuming FNM_DIR lives at ~/.fnm—it now reads fnm’s own data directory from fnm env and prints it at startup.
Picking the Right Tool Versions, Correctly
A handful of fixes here that all amount to the same lesson: check the thing you actually care about, not a proxy for it.
- Yeoman and gulp-cli versions are now resolved against the npm
latestdist-tag rather than sorted by hand, because a deprecated shim like yarn 2.4.3 sorts above 1.22.22 if you’re just comparing version strings. - The Heft check used to be “is Heft present,” which meant a stale Heft 1.1.2 from an older install satisfied a SPFx version that actually needed 1.23.2. It now checks the pinned version.
- If the task runner can’t be determined with confidence, the script now errors out instead of quietly defaulting to gulp. Gulp was a reasonable guess in December; it’s a silent correctness bug in production.
Keeping It a Single File
Partway through September’s cleanup I extracted the shared logic into scripts/lib/spfx-common.js so install-spfx.js, list-spfx.js, and uninstall-spfx.js could share code instead of drifting apart. Then I reverted it. The entire point of this tool is that I copy one file to a fresh machine and run it—no install step, no dependency resolution, nothing to forget. A shared lib module breaks that the moment you’re on a machine with only install-spfx.js on it. So each script carries its own copy of the helpers it needs, verified by running each one from an empty directory on a clean machine, and spfx-common.js is gone.
Compatibility Matrix Corrections
The fallback matrix for pre-engines SPFx versions (1.0.0–1.18.2) got rebuilt keyed by major.minor instead of exact version, which also made it cover prereleases it had never listed before. I cross-checked it against the Microsoft Learn compatibility chart and fixed off-by-one Node line errors at 1.3, 1.4, 1.8, and 1.11 (1.11/1.12 now correctly list Node 10 and 12 as alternatives—Node 11 had snuck in as allowed). The on-prem aliases also got corrected: SSE was renamed to SPSE and repointed to the right version, and SP2016 now resolves to 1.1.3, the last release for Feature Pack 2, instead of the version I’d originally guessed at.
And since the version source itself matters: SPFx versions are now read from generator-sharepoint’s registry metadata instead of sp-core-library—107 versions instead of 115, but six legitimate patch releases that used to get rejected now install correctly.
New Capabilities Along the Way
Not everything was bug fixing. A few additions came out of actually living with the tool:
-pnpmand-yarnflags, resolved against the target Node and installed before scaffolding.-forceon an existing alias now refreshes only the packages already installed globally, instead of reinstalling everything.- A Node 6 / npm 3 compatibility path for the oldest SPFx releases (1.0–1.2): npm 3 can’t install scoped packages, so the script downloads npm 6.14.18 and swaps it in via a staged, rename-based promotion with rollback if the swap is interrupted—verified on WSL.
- I added a
-currentmode in March for shell-prompt integration (terse output showing the active fnm alias), then removed it again at the end of September once I realized I wasn’t actually using it day to day. Not every feature earns its keep.
Where AI Fit In This Round
The original post was upfront that GitHub Copilot (on Claude Sonnet 4.5) helped build the first version. Starting August 11, the hardening work above was done with Claude directly, and I want to keep that same transparency going. The shape of the collaboration shifted a bit from round one: this wasn’t “describe a feature, get code,” it was closer to adversarial code review—throwing real-world version strings and registry responses at the existing script, finding where my original parser disagreed with actual npm semver, and fixing it with tests attached. A couple of the fixes (the || bug especially) are the kind of thing that’s easy to write confidently and never notice is wrong, because the fallback behavior looks plausible. That’s exactly the category of bug a second, more skeptical pass is good at catching.
Key Takeaways
- Real use is the test suite you didn’t write: every meaningful bug fix above came from actually running the script against client environments, not from re-reading my own code.
- A fallback that “looks reasonable” can hide a real bug: the
||parsing bug never looked like a failure—it looked like a slightly conservative default, for months. - Reuse beats provisioning from scratch: sharing Node installs where it’s safe to, and keeping dedicated installs where it isn’t, cut real time and disk usage.
- Single-file simplicity is a feature, not a shortcut: I tried modularizing it and reverted, because the whole value proposition is “copy one file, run it.”
- Fail closed, not silently: defaulting to gulp when the task runner is uncertain, or claiming “activated” when it wasn’t, are the kind of silent failures that erode trust in automation faster than an honest error message does.
The script is still at the PnP Script Samples repository if you want to see where it’s landed.
See ya soon & happy coding!
#sharing-is-caring
