The SPFx Install Script Grows Up

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 works out the right Node.js, Yeoman, generator, and task runner versions by querying the npm registry directly instead of relying on a hardcoded matrix (older releases keep a small fallback table, more on that below).

Then I used it every day on real client work, which turned out to be the best possible test suite. What happens when a tool you’re proud of meets real life? It broke in ways I didn’t anticipate, I rebuilt parts I thought were already solid, and it picked up a handful of features I only discovered I needed once I was living in it.

TL;DR Just show what I learned!

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 flag that installs the complete dev environment), 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 meant deciding when it’s safe to share a Node install between SPFx versions and when it isn’t. Here’s the rule I landed on:

  • An install with the scaffolding tools (Yeoman and the generator) stays dedicated to exactly one SPFx alias, because different SPFx versions can need different Yeoman versions.
  • An install with only 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 mixing scaffolding tools into 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. In the original post I wrote “this handles >=, <, ^, ~, and ||” with some confidence—turns out confidence and correctness are different things.

Here’s the kicker, and the worst one: my parser ANDed || alternatives together instead of ORing them. Yeoman’s 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.

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.13 or ^18 (no patch, sometimes no minor) didn’t parse, and the script treated anything it couldn’t parse as compatible—the opposite of fail-safe.
  • A stray space in the published SPFx range (>=22.14.0 < 23.0.0) made every Node 22 install look incompatible, because the parser choked on the space after <.
  • Prerelease versions were getting picked for yo (Yeoman) and gulp-cli when they shouldn’t have been eligible at all.
  • Wildcard and upper-only ranges (~18, a bare 18, *) needed to follow actual npm semver rules, not my approximation of them.

That last one was live and biting. pnpm 12 declares its Node range as >=18.*, my parser didn’t understand it, and so the script silently fell back to pnpm 11 on a Node 22 install. If you set up an alias before the fix, it still has the old pnpm; run install-spfx <version> -pnpm -force to bring it up to date.

There had also quietly been two different range-parsing implementations in the file that disagreed with each other on edge cases. I unified them into one determineCompatibleVersion path, which also made the parser far easier to test 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. That 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.

Now every global install goes through the target version’s own npm explicitly. On Linux and macOS that means running npm-cli.js directly with the target Node binary, because npm’s own launcher script (#!/usr/bin/env node) resolved to the PATH Node regardless of which Node I’d pointed it at.

I also cleaned up two bad assumptions. The script no longer claims an environment is “activated” when activation actually failed, and it no longer assumes FNM_DIR lives at ~/.fnm—it 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.

  • The script now resolves Yeoman and gulp-cli versions against the npm latest dist-tag rather than sorting 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 a newer one. Now the script looks up which Heft version the SPFx generator specifies for that release and installs exactly that, skipping the install only if what’s already there satisfies it.
  • If the script can’t determine the task runner with confidence, it 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, I verified each one by running it from an empty directory on a clean machine, and spfx-common.js is gone.

Compatibility Matrix Corrections

I rebuilt the fallback matrix for pre-engines SPFx versions (1.0.0–1.12.0), keying it by major.minor instead of exact version, which also made it cover prereleases it had never listed before. I cross-checked it against the SharePoint Framework compatibility chart on Microsoft Learn and fixed off-by-one Node line errors at 1.3, 1.4, 1.8, and 1.11. (1.11 now lists only Node 10, and 1.12.0 lists Node 10 or 12—Node 11 had snuck in as allowed.)

I also corrected the Subscription Edition alias. SSE was renamed to SPSE and repointed from 1.15.2 to 1.5.1, the last release Microsoft supports for Subscription Edition.

And since the version source itself matters: the script now reads SPFx versions from generator-sharepoint’s registry metadata instead of sp-core-library. The old list had 115 entries, including versions that were never real SPFx releases, and it was missing six legitimate patch releases that now install correctly. The new list has 107 legitimate SPFx versions.

New Capabilities Along the Way

Not everything was bug fixing. A few additions came out of actually living with the tool:

FlagWhat it does
-pnpmInstalls pnpm, resolved against the target Node, before scaffolding
-yarnSame idea for yarn
-forceOn an existing alias, refreshes only the packages already installed globally instead of reinstalling everything

Two more worth mentioning:

  • An npm 3 compatibility path for the oldest SPFx releases (1.0 through 1.4.0), where Node 6 is the only allowed version. Node 6 ships with npm 3, and npm 3 fails on those releases. After installing Node, the script checks which version of npm was included, and if it’s npm 3, it replaces it with npm 6.14.18.
  • -current mode: I added this 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 in August, I did the hardening work above with Claude Code directly, using various models, and I want to keep that same transparency going.

The collaboration looked different from round one. This wasn’t “describe a feature, get code,” but closer to an adversarial code review. We threw real-world version strings and registry responses at the existing script, found where my original parser disagreed with actual npm semver, and fixed each one with tests attached.

A couple of those 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 starting from scratch: sharing Node installs where it’s safe, and keeping dedicated installs where it isn’t, saved real time and disk space.
  • Keeping it a single file is a feature, not a shortcut: I tried splitting it up and reverted, because the whole point is “copy one file, run it.”
  • Fail loudly, 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. If you try it and hit something it gets wrong, please reach out—real version strings are the best bug reports I get, and I’m still learning alongside you.


See ya soon & happy coding!
#sharing-is-caring