From 282d7e127cd1e5c005f06196eaf014458b929f52 Mon Sep 17 00:00:00 2001 From: Tommy Carpenter <9419125+tommyjcarpenter@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:35:20 -0400 Subject: [PATCH 1/2] Skip already-installed npm globals so bootstrap reruns are idempotent The `npm` package type now lists the installed globals with `npm ls -g --depth=0 --json` and runs `npm install -g` only for the missing ones. `npm install -g` retires and re-extracts every named package, even one that is already current. On Windows that rename fails with EBUSY while another process holds a file in the package dir open, such as an editor's running `pyright` language server, so a plain rerun of the bootstrap aborted at the npm step. The winget step already skips installed packages the same way. Reruns no longer upgrade npm globals; `npm update -g` does that. --- Changelog.md | 3 ++ bootstrap/utils.py | 48 +++++++++++++++++++++++++++++-- tests/test_npm.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 tests/test_npm.py diff --git a/Changelog.md b/Changelog.md index 2b17fd8..689cee2 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +- `npm` package type installs only globals that are not installed yet (`npm ls -g --depth=0 --json` precheck). `npm install -g` re-extracts already-installed packages, which fails with EBUSY on Windows while an editor's language server holds files in the package dir, so bootstrap reruns aborted. Reruns no longer upgrade npm globals; use `npm update -g` for that. + ## [1.3.1] - 2026-06-05 - switch project tooling from Poetry to `uv`: PEP 621 `pyproject.toml`, `uv.lock`, hatchling build backend, `Makefile` and CI workflows driven by `uv sync`/`uv run` diff --git a/bootstrap/utils.py b/bootstrap/utils.py index 77a1099..a598acd 100644 --- a/bootstrap/utils.py +++ b/bootstrap/utils.py @@ -1,5 +1,6 @@ """install everything""" +import json import os import shutil import subprocess @@ -40,6 +41,9 @@ # OS-specific package types first, then cross-platform (ensures npm/go are installed before used) ALL_KNOWN_PACKAGE_TYPES = OS_SPECIFIC_PACKAGE_ORDER + CROSS_PLATFORM_PACKAGE_ORDER +NPM_LIST_GLOBALS_CMD = "npm ls -g --depth=0 --json" +NPM_LIST_TIMEOUT_SECONDS = 60 + def _replace_home(path): """fix home with full path""" @@ -179,6 +183,46 @@ def _run_check(check_cmd): return False +def _installed_npm_globals() -> set[str]: + """Names of globally installed npm packages. Empty when npm is missing or prints no JSON.""" + # `npm ls` exits 1 on extraneous or invalid deps but still prints the full tree, so the exit code is ignored + result = subprocess.run( + NPM_LIST_GLOBALS_CMD, + shell=True, + executable=SHELLPATH, + capture_output=True, + text=True, + timeout=NPM_LIST_TIMEOUT_SECONDS, + ) + try: + return set(json.loads(result.stdout).get("dependencies", {})) + except json.JSONDecodeError: + return set() + + +def _install_npm_packages(pkgs: list[str]) -> None: + """Globally install only the npm packages that are not installed yet. + + `npm install -g` retires and re-extracts every named package even when it is already current. On Windows that + rename fails with EBUSY while any process holds a file in the package dir open (e.g. an editor's running + pyright language server), so a plain rerun of the bootstrap aborts. Skipping installed packages keeps reruns + idempotent. The tradeoff is that reruns no longer upgrade npm globals; use `npm update -g` for that. + A spec with a version suffix (`pkg@1.2`) never matches an installed name, so it always installs. + """ + installed = _installed_npm_globals() + missing = [] + for pkg in pkgs: + if pkg in installed: + log.skip(f"npm: {pkg} already installed") + continue + missing.append(pkg) + if not missing: + return + # Windows npm has no sudo and doesn't need it; POSIX requires sudo for -g installs + prefix = "" if sys.platform == "win32" else "sudo " + _run_cmd(f"{prefix}npm install {' '.join(missing)} -g") + + def cmds(config, systype, section_key="commands"): """run all commands from config[section_key][systype]. section_key is 'commands' for the pre-package step (default) and 'post_commands' for the post-package step. @@ -244,9 +288,7 @@ def _install_packages(inner, label): case "fisher": _run_cmd("fisher install " + " ".join(inner["fisher"])) case "npm": - # Windows npm has no sudo and doesn't need it; POSIX requires sudo for -g installs - prefix = "" if sys.platform == "win32" else "sudo " - _run_cmd("{0}npm install {1} -g".format(prefix, " ".join(inner["npm"]))) + _install_npm_packages(inner["npm"]) case "pip": # Windows: winget Python is a per-user install at %LOCALAPPDATA%\Programs\Python\PythonXX\ # whose Scripts dir is already on PATH; plain `pip install` writes there. We deliberately diff --git a/tests/test_npm.py b/tests/test_npm.py new file mode 100644 index 0000000..05c9ab7 --- /dev/null +++ b/tests/test_npm.py @@ -0,0 +1,72 @@ +"""Tests for the idempotent npm global install step in bootstrap.utils.""" + +import json +import subprocess +import unittest +from unittest import mock + +from bootstrap import utils + +INSTALLED_TREE = { + "name": "npm", + "dependencies": { + "pyright": {"version": "1.1.411"}, + "@google/gemini-cli": {"version": "0.49.0"}, + }, +} + + +def _completed(stdout: str, returncode: int = 0) -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(args=utils.NPM_LIST_GLOBALS_CMD, returncode=returncode, stdout=stdout) + + +class TestInstalledNpmGlobals(unittest.TestCase): + def _run_with_stdout(self, stdout: str, returncode: int = 0) -> set[str]: + with mock.patch.object(utils.subprocess, "run", return_value=_completed(stdout, returncode)): + return utils._installed_npm_globals() + + def test_returns_package_names(self): + self.assertEqual(self._run_with_stdout(json.dumps(INSTALLED_TREE)), {"pyright", "@google/gemini-cli"}) + + def test_parses_tree_when_npm_ls_exits_nonzero(self): + self.assertEqual( + self._run_with_stdout(json.dumps(INSTALLED_TREE), returncode=1), {"pyright", "@google/gemini-cli"} + ) + + def test_no_globals_installed(self): + self.assertEqual(self._run_with_stdout(json.dumps({"name": "npm"})), set()) + + def test_empty_output_when_npm_missing(self): + self.assertEqual(self._run_with_stdout("", returncode=1), set()) + + +class TestInstallNpmPackages(unittest.TestCase): + def setUp(self): + installed = mock.patch.object(utils, "_installed_npm_globals", return_value={"pyright", "@google/gemini-cli"}) + run_cmd = mock.patch.object(utils, "_run_cmd") + installed.start() + self.run_cmd = run_cmd.start() + self.addCleanup(mock.patch.stopall) + + def test_skips_install_when_all_present(self): + utils._install_npm_packages(["pyright", "@google/gemini-cli"]) + self.run_cmd.assert_not_called() + + def test_installs_only_missing_on_windows(self): + with mock.patch.object(utils.sys, "platform", "win32"): + utils._install_npm_packages(["pyright", "yaml-language-server", "bash-language-server"]) + self.run_cmd.assert_called_once_with("npm install yaml-language-server bash-language-server -g") + + def test_installs_only_missing_with_sudo_on_posix(self): + with mock.patch.object(utils.sys, "platform", "linux"): + utils._install_npm_packages(["@google/gemini-cli", "yaml-language-server"]) + self.run_cmd.assert_called_once_with("sudo npm install yaml-language-server -g") + + def test_versioned_spec_always_installs(self): + with mock.patch.object(utils.sys, "platform", "win32"): + utils._install_npm_packages(["pyright@1.1.400"]) + self.run_cmd.assert_called_once_with("npm install pyright@1.1.400 -g") + + +if __name__ == "__main__": + unittest.main() From ecee97f724434f8c00766cc071c0f1363b9abffb Mon Sep 17 00:00:00 2001 From: Tommy Carpenter <9419125+tommyjcarpenter@users.noreply.github.com> Date: Tue, 29 Sep 2026 07:38:22 -0400 Subject: [PATCH 2/2] Release the npm precheck as 1.3.2 Gives the changelog entry a version instead of Unreleased and bumps `pyproject.toml` and `uv.lock` to match. Drops the note telling users to run `npm update -g` by hand; upgrading installed globals is the machine's update routine, not bootstrap's. --- Changelog.md | 4 ++-- bootstrap/utils.py | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Changelog.md b/Changelog.md index 689cee2..2e36a21 100644 --- a/Changelog.md +++ b/Changelog.md @@ -4,8 +4,8 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] -- `npm` package type installs only globals that are not installed yet (`npm ls -g --depth=0 --json` precheck). `npm install -g` re-extracts already-installed packages, which fails with EBUSY on Windows while an editor's language server holds files in the package dir, so bootstrap reruns aborted. Reruns no longer upgrade npm globals; use `npm update -g` for that. +## [1.3.2] - 2026-09-29 +- `npm` package type installs only the globals that are not installed yet (`npm ls -g --depth=0 --json` precheck), so a bootstrap rerun is idempotent. `npm install -g` re-extracts packages that are already installed, and on Windows that fails with EBUSY while a running process, such as an editor's language server, holds files in the package dir. ## [1.3.1] - 2026-06-05 - switch project tooling from Poetry to `uv`: PEP 621 `pyproject.toml`, `uv.lock`, hatchling build backend, `Makefile` and CI workflows driven by `uv sync`/`uv run` diff --git a/bootstrap/utils.py b/bootstrap/utils.py index a598acd..bf368b1 100644 --- a/bootstrap/utils.py +++ b/bootstrap/utils.py @@ -206,7 +206,7 @@ def _install_npm_packages(pkgs: list[str]) -> None: `npm install -g` retires and re-extracts every named package even when it is already current. On Windows that rename fails with EBUSY while any process holds a file in the package dir open (e.g. an editor's running pyright language server), so a plain rerun of the bootstrap aborts. Skipping installed packages keeps reruns - idempotent. The tradeoff is that reruns no longer upgrade npm globals; use `npm update -g` for that. + idempotent. A spec with a version suffix (`pkg@1.2`) never matches an installed name, so it always installs. """ installed = _installed_npm_globals() diff --git a/pyproject.toml b/pyproject.toml index a53f321..8116e40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bootstrap" -version = "1.3.1" +version = "1.3.2" description = "Automatic setup of new machines" authors = [{ name = "Tommy Carpenter", email = "tommyjcarpenter@gmail.com" }] license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index b3c5cb4..2a3c7a3 100644 --- a/uv.lock +++ b/uv.lock @@ -17,7 +17,7 @@ wheels = [ [[package]] name = "bootstrap" -version = "1.3.1" +version = "1.3.2" source = { editable = "." } dependencies = [ { name = "click" },