Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

## [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`

Expand Down
48 changes: 45 additions & 3 deletions bootstrap/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""install everything"""

import json
import os
import shutil
import subprocess
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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.
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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }
Expand Down
72 changes: 72 additions & 0 deletions tests/test_npm.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading