Development environment gotchas
Two setups where a plain npm install or a plain test run does not work:
locked-down containers, and machines without a Vulkan driver. The everyday
setup is AGENTS.md § Prerequisites.
TypeScript compiler roles
Normal repository builds and type-checks use the TypeScript 7 native compiler. Compiler watch commands use TypeScript 6 because TypeScript 7.0.2 can miss filesystem changes on Linux and in containers (upstream issue). The repository also installs TypeScript 6 as the compatibility package for tools that use the TypeScript compiler API, including Jest and documentation tooling. The shared launcher keeps these roles explicit and avoids npm bin link order deciding which compiler a script runs.
To select a compiler for a command, set NODETOOL_TSC_VERSION:
NODETOOL_TSC_VERSION=7 npm run typecheck
NODETOOL_TSC_VERSION=6 npm run typecheck
The named :ts6 and :ts7 package commands are the preferred rollback and
comparison entry points. NODETOOL_TSC_HEAP_MB controls the V8 heap only for
the TypeScript 6 JavaScript compiler. The TypeScript 7 native compiler does not
receive that Node heap option.
If a compiler-role check fails after install, run npm run check:typescript
before attempting a build. It reports missing or mismatched compiler packages,
native platform packages, and direct compiler-path drift.
Local test and build memory
Local Turbo scripts run one task at a time, and local Jest and Vitest suites use
two workers. This leaves memory for macOS and the editor while checking the
same code. CI keeps its existing parallelism. To tune a local run, set
NODETOOL_TURBO_CONCURRENCY for Turbo tasks or NODETOOL_TEST_WORKERS for
test workers, for example:
NODETOOL_TEST_WORKERS=1 npm run test:affected
NODETOOL_TURBO_CONCURRENCY=2 npm run build:packages
TypeScript 7 is a native process, so Node’s heap flags do not limit its memory.
The root npm run typecheck checks web, Electron, and mobile sequentially.
libc in the lockfile
npm selects a prebuilt native package by the os, cpu and libc recorded in
package-lock.json. npm 10 reads libc from the registry but never writes it,
which makes npm ci reify both the glibc and the musl build of every such
package — npm install filters them, npm ci does not, so it lands in CI, the
Docker image and every fresh clone rather than on a developer’s machine.
npm run fix:lockfile-libc backfills the field from the registry, and
npm run check:lockfile-libc (part of npm run check) fails when it goes
missing again. Rerun the fix after any npm install under npm 10 that rewrites
the lock, and commit the result.
Worktrees with cloned dependencies (macOS)
A full npm install in a new worktree takes minutes and several GB. On APFS,
clone the dependency and build folders from an existing checkout instead.
cp -c makes a copy-on-write clone: it takes seconds, uses almost no disk,
and a later write in one tree does not change the other.
Run scripts/setup-agent-env.sh inside a new worktree to do this
automatically. It finds the main checkout through git, clones with cp -cR on
macOS or cp -R --reflink=always on Linux, skips worktrees nested in the main
checkout, and runs npm install when the file system cannot clone. The manual
steps:
MAIN=~/workspace/nodetool # checkout with installed deps
WT=~/workspace/nodetool-wt/<name>
git -C "$MAIN" fetch origin
git -C "$MAIN" worktree add -b <branch> "$WT" origin/main
cd "$MAIN"
{ find . -name node_modules -type d -prune | grep -v '/node_modules/.*node_modules'
find . -path ./node_modules -prune -o -name dist -type d -prune -print | grep -v node_modules
} | sort -u | while read -r d; do
mkdir -p "$WT/$(dirname "$d")" && cp -cR "$d" "$WT/$d"
done
cd "$WT" && npm run build:packages # the cloned dist/ is from the source branch
- The workspace links in
node_modules/@nodetool-ai/are relative, so they resolve to the new worktree’spackages/. - The cloned
dist/folders match the source checkout’s branch. Runnpm run build:packagesbefore you trust a test or harness result. ducounts a clone at full size. Measure the space you get back withdf.- On Linux,
cp --reflink=autodoes the same on btrfs and XFS. On other file systems it makes a full copy, so runnpm ciinstead.
Change dependencies in a worktree
An install in a worktree writes only to that worktree’s clones. The source checkout does not change.
- A failed root
postinstalldeletes thebetter-sqlite3binary it tried to rebuild. Clone it back withcp -cR "$MAIN/node_modules/better-sqlite3/build" node_modules/better-sqlite3/, or install with--ignore-scriptsand runnpm run rebuild:native. - After
npm installchanges a version range, runnpm explain <package>. npm can place the new version underpackages/<name>/node_modules/instead of the root. A package that the runtime loads by name, such asnode-llama-cpp, must stay at the root. Record the root placement in the lockfile. A laternpm installkeeps a placement that already satisfies the range. - npm 10 removes
libcon every install that rewrites the lockfile. Runnpm run fix:lockfile-libclast, and confirm withnpm run check:lockfile-libc. mobile/node_modulesis cloned with the rest but can be incomplete. Runnpm --prefix mobile cibefore mobile typecheck or tests.
Remove worktrees
git worktree remove --force deletes uncommitted work. First list the work
that only the worktree holds:
git -C "$WT" status --porcelain # uncommitted
git -C "$WT" rev-list --count HEAD --not --remotes # unpushed commits
Commit anything you keep to a branch, then run
git worktree remove --force "$WT" and git worktree prune.
Install in sandboxed / proxied environments
Three postinstall steps break npm install in locked-down containers (CI
sandboxes, Claude Code on the web, proxied networks). A failed postinstall
makes npm roll back the entire node_modules tree, so one bad package
means no dependencies at all — including ESLint and the design-lint gate.
keytarneeds libsecret headers on Linux. Without them node-gyp fails withPackage libsecret-1 was not found. Fix first:apt-get install -y libsecret-1-dev.electrondownloads its binary in postinstall. Proxies that block the download (HTTP 403) fail the install. Skip it when you don’t need to launch Electron:ELECTRON_SKIP_BINARY_DOWNLOAD=1 npm install.onnxruntime-nodedownloads CUDA binaries from GitHub releases in postinstall (it assumes CUDA whennvccis absent). Same proxy failure mode, and there is no skip env var in our pinned version.
When only the JS toolchain matters (typecheck, lint, unit tests that don’t hit native modules), bypass all of the above in one step:
npm install --ignore-scripts --no-audit --no-fund
This skips every postinstall — including the root better-sqlite3 rebuild —
so anything touching the database needs npm run rebuild:native afterwards
(which will still require the downloads above to have succeeded).
WebGPU on a headless machine
The image nodes are shader-backed: every lib.image.* generator and every
nodetool.image transform reaches WebGPU through Dawn. On a machine with no
Vulkan driver they fail with:
No WebGPU adapter available (Node/Dawn). On headless Linux this usually means
no Vulkan driver (ICD) is installed — Dawn has no software fallback of its own.
This is an environment gap, not a broken test and not an unsupported
platform. CI already solves it: the test-packages-* shards of
.github/workflows/quality-checks.yml and the browser job in test.yml both
install mesa-vulkan-drivers, which ships lavapipe — a CPU Vulkan ICD. Do
not conclude from this error that shader-backed nodes cannot be tested, and do
not skip a test because your box hits it; the same test passes in CI.
With root:
apt-get install -y mesa-vulkan-drivers
Without root (sandboxes, dev containers), extract the driver and point the
Vulkan loader at it. libvulkan1 — the loader — is usually already present;
only the ICD is missing:
apt-get download mesa-vulkan-drivers
dpkg-deb -x mesa-vulkan-drivers_*.deb /tmp/vk
# The shipped manifest names the library relatively, so rewrite it absolutely:
python3 - <<'PY'
import json
p = "/tmp/vk/usr/share/vulkan/icd.d/lvp_icd.json"
d = json.load(open(p))
d["ICD"]["library_path"] = "/tmp/vk/usr/lib/x86_64-linux-gnu/libvulkan_lvp.so"
json.dump(d, open("/tmp/vk/lvp_icd.json", "w"))
PY
export VK_DRIVER_FILES=/tmp/vk/lvp_icd.json
Then run the tests as usual. Lavapipe is a software rasterizer, so it is slow
but exact — pixel comparisons (nodetool.compare.CompareImages) are
reproducible under it, which is what
packages/base-nodes/tests/image-examples-run.test.ts relies on.