Coverage for lintro / utils / node_deps.py: 89%
88 statements
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Node.js dependency management utilities.
3Provides functions for detecting and installing Node.js dependencies,
4primarily used by tools that depend on node_modules (like tsc).
5"""
7from __future__ import annotations
9import os
10import shutil
11import subprocess # nosec B404 - used safely with shell disabled
12import time
13from pathlib import Path
15from loguru import logger
17from lintro.utils.env import get_subprocess_env
20def should_install_deps(cwd: Path) -> bool:
21 """Check if Node.js dependencies should be installed.
23 Returns True if:
24 - package.json exists in the directory
25 - node_modules directory is missing or empty
27 Args:
28 cwd: Directory to check for package.json and node_modules.
30 Returns:
31 True if dependencies should be installed, False otherwise.
33 Raises:
34 PermissionError: If the directory is not writable+executable
35 (e.g., read-only mount).
37 Examples:
38 >>> from pathlib import Path
39 >>> # In a directory with package.json but no node_modules
40 >>> should_install_deps(Path("/project")) # doctest: +SKIP
41 True
42 """
43 package_json = cwd / "package.json"
44 node_modules = cwd / "node_modules"
46 if not package_json.exists():
47 logger.debug("[node_deps] No package.json found in {}", cwd)
48 return False
50 if not node_modules.exists():
51 # Check if the directory is writable and executable before claiming
52 # deps are needed. On read-only mounts (e.g., -v "...:/code:ro"),
53 # bun install would fail with EACCES.
54 if not os.access(cwd, os.W_OK | os.X_OK):
55 msg = (
56 f"Cannot install dependencies: {cwd} is not writable "
57 f"(read-only mount?)"
58 )
59 logger.warning("[node_deps] {}", msg)
60 raise PermissionError(msg)
61 logger.debug("[node_deps] node_modules missing in {}", cwd)
62 return True
64 # Check if node_modules is empty (besides potential .bin directory)
65 try:
66 for entry in node_modules.iterdir():
67 if entry.name != ".bin":
68 logger.debug(
69 "[node_deps] Dependencies appear to be installed in {}",
70 cwd,
71 )
72 return False
73 logger.debug("[node_deps] node_modules is effectively empty in {}", cwd)
74 return True
75 except OSError as e:
76 logger.debug("[node_deps] Error checking node_modules: {}", e)
77 return True
80def get_package_manager_command() -> list[str] | None:
81 """Determine which package manager to use for installation.
83 Checks for available package managers in order of preference:
84 1. bun (fastest)
85 2. npm (most common)
87 Returns:
88 Command list for installation, or None if no package manager found.
90 Examples:
91 >>> cmd = get_package_manager_command()
92 >>> cmd is not None # doctest: +SKIP
93 True
94 """
95 # Prefer bun for speed
96 # --ignore-scripts prevents lifecycle script execution for security
97 if shutil.which("bun"):
98 return ["bun", "install", "--ignore-scripts"]
100 # Fallback to npm
101 # --ignore-scripts prevents lifecycle script execution for security
102 if shutil.which("npm"):
103 return ["npm", "install", "--ignore-scripts"]
105 return None
108def install_node_deps(
109 cwd: Path,
110 timeout: int = 120,
111) -> tuple[bool, str]:
112 """Install Node.js dependencies using the available package manager.
114 Attempts to install dependencies using bun or npm, preferring bun
115 for speed. Uses frozen lockfile when available, falling back to
116 regular install.
118 Args:
119 cwd: Directory containing package.json where installation should run.
120 timeout: Maximum time in seconds to wait for installation.
122 Returns:
123 Tuple of (success, output) where:
124 - success: True if installation completed successfully
125 - output: Combined stdout/stderr from the installation command
127 Examples:
128 >>> from pathlib import Path
129 >>> success, output = install_node_deps(Path("/project")) # doctest: +SKIP
130 >>> success
131 True
132 """
133 # First check if we should install
134 try:
135 if not should_install_deps(cwd):
136 return True, "Dependencies already installed"
137 except PermissionError as e:
138 return False, str(e)
140 # Get the package manager command
141 base_cmd = get_package_manager_command()
142 if not base_cmd:
143 return False, (
144 "No package manager found. Please install bun or npm.\n"
145 " - Install bun: curl -fsSL https://bun.sh/install | bash\n"
146 " - Install npm: https://nodejs.org/"
147 )
149 manager_name = base_cmd[0]
150 logger.info("[node_deps] Installing dependencies with {} in {}", manager_name, cwd)
152 run_env = get_subprocess_env()
154 # Try with frozen lockfile first (for CI reproducibility)
155 frozen_cmd = _get_frozen_install_cmd(base_cmd)
156 start_time = time.monotonic()
158 try:
159 result = subprocess.run( # nosec B603 - command is constructed safely
160 frozen_cmd,
161 cwd=cwd,
162 capture_output=True,
163 text=True,
164 timeout=timeout,
165 shell=False,
166 env=run_env,
167 )
169 if result.returncode == 0:
170 output = result.stdout + result.stderr
171 logger.info("[node_deps] Dependencies installed successfully")
172 return True, output.strip()
174 # Frozen install failed, try regular install
175 logger.debug(
176 "[node_deps] Frozen install failed, trying regular install: {}",
177 result.stderr,
178 )
180 except subprocess.TimeoutExpired:
181 logger.warning("[node_deps] Frozen install timed out, trying regular install")
182 except OSError as e:
183 logger.debug("[node_deps] Frozen install failed with OS error: {}", e)
185 # Calculate remaining timeout for fallback
186 elapsed = time.monotonic() - start_time
187 remaining_timeout = max(0, timeout - elapsed)
189 if remaining_timeout <= 0:
190 return False, f"Installation timed out after {timeout} seconds"
192 # Fallback to regular install (without frozen lockfile)
193 try:
194 result = subprocess.run( # nosec B603 - command is constructed safely
195 base_cmd,
196 cwd=cwd,
197 capture_output=True,
198 text=True,
199 timeout=remaining_timeout,
200 shell=False,
201 env=run_env,
202 )
204 output = result.stdout + result.stderr
206 if result.returncode == 0:
207 logger.info("[node_deps] Dependencies installed successfully")
208 return True, output.strip()
210 logger.error("[node_deps] Installation failed: {}", result.stderr)
211 return False, output.strip()
213 except subprocess.TimeoutExpired:
214 return False, f"Installation timed out after {timeout} seconds"
215 except OSError as e:
216 return False, f"Installation failed: {e}"
219def _get_frozen_install_cmd(base_cmd: list[str]) -> list[str]:
220 """Get the frozen lockfile install command for a package manager.
222 Args:
223 base_cmd: Base installation command
224 (e.g., ["bun", "install", "--ignore-scripts"]).
226 Returns:
227 Command with frozen lockfile flag added.
229 Raises:
230 ValueError: If base_cmd is empty.
231 """
232 if not base_cmd:
233 raise ValueError("base_cmd cannot be empty")
235 manager = base_cmd[0]
237 if manager == "bun":
238 return [*base_cmd, "--frozen-lockfile"]
239 if manager == "npm":
240 # npm ci is the equivalent of frozen lockfile for npm
241 # Preserve any flags from base_cmd (e.g., --ignore-scripts)
242 extra_flags = base_cmd[2:] # Skip "npm" and "install"
243 return ["npm", "ci", *extra_flags]
245 return base_cmd