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

1"""Node.js dependency management utilities. 

2 

3Provides functions for detecting and installing Node.js dependencies, 

4primarily used by tools that depend on node_modules (like tsc). 

5""" 

6 

7from __future__ import annotations 

8 

9import os 

10import shutil 

11import subprocess # nosec B404 - used safely with shell disabled 

12import time 

13from pathlib import Path 

14 

15from loguru import logger 

16 

17from lintro.utils.env import get_subprocess_env 

18 

19 

20def should_install_deps(cwd: Path) -> bool: 

21 """Check if Node.js dependencies should be installed. 

22 

23 Returns True if: 

24 - package.json exists in the directory 

25 - node_modules directory is missing or empty 

26 

27 Args: 

28 cwd: Directory to check for package.json and node_modules. 

29 

30 Returns: 

31 True if dependencies should be installed, False otherwise. 

32 

33 Raises: 

34 PermissionError: If the directory is not writable+executable 

35 (e.g., read-only mount). 

36 

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" 

45 

46 if not package_json.exists(): 

47 logger.debug("[node_deps] No package.json found in {}", cwd) 

48 return False 

49 

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 

63 

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 

78 

79 

80def get_package_manager_command() -> list[str] | None: 

81 """Determine which package manager to use for installation. 

82 

83 Checks for available package managers in order of preference: 

84 1. bun (fastest) 

85 2. npm (most common) 

86 

87 Returns: 

88 Command list for installation, or None if no package manager found. 

89 

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"] 

99 

100 # Fallback to npm 

101 # --ignore-scripts prevents lifecycle script execution for security 

102 if shutil.which("npm"): 

103 return ["npm", "install", "--ignore-scripts"] 

104 

105 return None 

106 

107 

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. 

113 

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. 

117 

118 Args: 

119 cwd: Directory containing package.json where installation should run. 

120 timeout: Maximum time in seconds to wait for installation. 

121 

122 Returns: 

123 Tuple of (success, output) where: 

124 - success: True if installation completed successfully 

125 - output: Combined stdout/stderr from the installation command 

126 

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) 

139 

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 ) 

148 

149 manager_name = base_cmd[0] 

150 logger.info("[node_deps] Installing dependencies with {} in {}", manager_name, cwd) 

151 

152 run_env = get_subprocess_env() 

153 

154 # Try with frozen lockfile first (for CI reproducibility) 

155 frozen_cmd = _get_frozen_install_cmd(base_cmd) 

156 start_time = time.monotonic() 

157 

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 ) 

168 

169 if result.returncode == 0: 

170 output = result.stdout + result.stderr 

171 logger.info("[node_deps] Dependencies installed successfully") 

172 return True, output.strip() 

173 

174 # Frozen install failed, try regular install 

175 logger.debug( 

176 "[node_deps] Frozen install failed, trying regular install: {}", 

177 result.stderr, 

178 ) 

179 

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) 

184 

185 # Calculate remaining timeout for fallback 

186 elapsed = time.monotonic() - start_time 

187 remaining_timeout = max(0, timeout - elapsed) 

188 

189 if remaining_timeout <= 0: 

190 return False, f"Installation timed out after {timeout} seconds" 

191 

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 ) 

203 

204 output = result.stdout + result.stderr 

205 

206 if result.returncode == 0: 

207 logger.info("[node_deps] Dependencies installed successfully") 

208 return True, output.strip() 

209 

210 logger.error("[node_deps] Installation failed: {}", result.stderr) 

211 return False, output.strip() 

212 

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}" 

217 

218 

219def _get_frozen_install_cmd(base_cmd: list[str]) -> list[str]: 

220 """Get the frozen lockfile install command for a package manager. 

221 

222 Args: 

223 base_cmd: Base installation command 

224 (e.g., ["bun", "install", "--ignore-scripts"]). 

225 

226 Returns: 

227 Command with frozen lockfile flag added. 

228 

229 Raises: 

230 ValueError: If base_cmd is empty. 

231 """ 

232 if not base_cmd: 

233 raise ValueError("base_cmd cannot be empty") 

234 

235 manager = base_cmd[0] 

236 

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] 

244 

245 return base_cmd