nodeutil.py (9300B)
1 # This Source Code Form is subject to the terms of the Mozilla Public 2 # License, v. 2.0. If a copy of the MPL was not distributed with this 3 # file, You can obtain one at http://mozilla.org/MPL/2.0/. 4 5 import os 6 import platform 7 import subprocess 8 import sys 9 10 from mozboot.util import get_tools_dir 11 from mozfile import which 12 from mozfile.mozfile import remove as mozfileremove 13 from packaging.version import Version 14 15 NODE_MIN_VERSION = Version("12.22.12") 16 NPM_MIN_VERSION = Version("6.14.16") 17 18 NODE_MACHING_VERSION_NOT_FOUND_MESSAGE = """ 19 Could not find Node.js executable later than %s. 20 21 Executing `mach bootstrap --no-system-changes` should 22 install a compatible version in ~/.mozbuild on most platforms. 23 """.strip() 24 25 NPM_MACHING_VERSION_NOT_FOUND_MESSAGE = """ 26 Could not find npm executable later than %s. 27 28 Executing `mach bootstrap --no-system-changes` should 29 install a compatible version in ~/.mozbuild on most platforms. 30 """.strip() 31 32 NODE_NOT_FOUND_MESSAGE = """ 33 nodejs is either not installed or is installed to a non-standard path. 34 35 Executing `mach bootstrap --no-system-changes` should 36 install a compatible version in ~/.mozbuild on most platforms. 37 """.strip() 38 39 NPM_NOT_FOUND_MESSAGE = """ 40 Node Package Manager (npm) is either not installed or installed to a 41 non-standard path. 42 43 Executing `mach bootstrap --no-system-changes` should 44 install a compatible version in ~/.mozbuild on most platforms. 45 """.strip() 46 47 48 def find_node_paths(): 49 """Determines the possible paths for node executables. 50 51 Returns a list of paths, which includes the build state directory. 52 """ 53 mozbuild_tools_dir = get_tools_dir() 54 55 if platform.system() == "Windows": 56 mozbuild_node_path = os.path.join(mozbuild_tools_dir, "node") 57 else: 58 mozbuild_node_path = os.path.join(mozbuild_tools_dir, "node", "bin") 59 60 # We still fallback to the PATH, since on OSes that don't have toolchain 61 # artifacts available to download, Node may be coming from $PATH. 62 paths = [mozbuild_node_path] + os.environ.get("PATH").split(os.pathsep) 63 64 if platform.system() == "Windows": 65 paths += [ 66 "%s\\nodejs" % os.environ.get("SystemDrive"), 67 os.path.join(os.environ.get("ProgramFiles"), "nodejs"), 68 os.path.join(os.environ.get("PROGRAMW6432"), "nodejs"), 69 os.path.join(os.environ.get("PROGRAMFILES"), "nodejs"), 70 ] 71 72 return paths 73 74 75 def check_executable_version(exe, wrap_call_with_node=False): 76 """Determine the version of a Node executable by invoking it. 77 78 May raise ``subprocess.CalledProcessError`` or ``ValueError`` on failure. 79 """ 80 out = None 81 # npm may be a script (Except on Windows), so we must call it with node. 82 if wrap_call_with_node and platform.system() != "Windows": 83 binary, _ = find_node_executable() 84 if binary: 85 out = ( 86 subprocess.check_output( 87 [binary, exe, "--version"], universal_newlines=True 88 ) 89 .lstrip("v") 90 .rstrip() 91 ) 92 93 # If we can't find node, or we don't need to wrap it, fallback to calling 94 # direct. 95 if not out: 96 out = ( 97 subprocess.check_output([exe, "--version"], universal_newlines=True) 98 .lstrip("v") 99 .rstrip() 100 ) 101 return Version(out) 102 103 104 def find_node_executable( 105 nodejs_exe=os.environ.get("NODEJS"), min_version=NODE_MIN_VERSION 106 ): 107 """Find a Node executable from the mozbuild directory. 108 109 Returns a tuple containing the the path to an executable binary and a 110 version tuple. Both tuple entries will be None if a Node executable 111 could not be resolved. 112 """ 113 if nodejs_exe: 114 try: 115 version = check_executable_version(nodejs_exe) 116 except (subprocess.CalledProcessError, ValueError): 117 return None, None 118 119 if version >= min_version: 120 return nodejs_exe, version.release 121 122 return None, None 123 124 # "nodejs" is first in the tuple on the assumption that it's only likely to 125 # exist on systems (probably linux distros) where there is a program in the path 126 # called "node" that does something else. 127 return find_executable("node", min_version) 128 129 130 def find_npm_executable(min_version=NPM_MIN_VERSION): 131 """Find a Node executable from the mozbuild directory. 132 133 Returns a tuple containing the the path to an executable binary and a 134 version tuple. Both tuple entries will be None if a Node executable 135 could not be resolved. 136 """ 137 return find_executable("npm", min_version, True) 138 139 140 def find_executable(name, min_version, use_node_for_version_check=False): 141 paths = find_node_paths() 142 exe = which(name, path=paths) 143 144 if not exe: 145 return None, None 146 147 # Verify we can invoke the executable and its version is acceptable. 148 try: 149 version = check_executable_version(exe, use_node_for_version_check) 150 except (subprocess.CalledProcessError, ValueError): 151 return None, None 152 153 if version < min_version: 154 return None, None 155 156 return exe, version.release 157 158 159 def check_node_executables_valid(): 160 node_path, version = find_node_executable() 161 if not node_path: 162 print(NODE_NOT_FOUND_MESSAGE) 163 return False 164 if not version: 165 print(NODE_MACHING_VERSION_NOT_FOUND_MESSAGE % NODE_MIN_VERSION) 166 return False 167 168 npm_path, version = find_npm_executable() 169 if not npm_path: 170 print(NPM_NOT_FOUND_MESSAGE) 171 return False 172 if not version: 173 print(NPM_MACHING_VERSION_NOT_FOUND_MESSAGE % NPM_MIN_VERSION) 174 return False 175 176 return True 177 178 179 def package_setup( 180 package_root, 181 package_name, 182 should_update=False, 183 should_clobber=False, 184 no_optional=False, 185 skip_logging=False, 186 ): 187 """Ensure `package_name` at `package_root` is installed. 188 189 When `should_update` is true, clobber, install, and produce a new 190 "package-lock.json" file. 191 192 This populates `package_root/node_modules`. 193 194 """ 195 orig_cwd = os.getcwd() 196 197 if should_update: 198 should_clobber = True 199 200 try: 201 # npm sometimes fails to respect cwd when it is run using check_call so 202 # we manually switch folders here instead. 203 os.chdir(package_root) 204 205 if should_clobber: 206 remove_directory(os.path.join(package_root, "node_modules"), skip_logging) 207 208 npm_path, _ = find_npm_executable() 209 if not npm_path: 210 return 1 211 212 node_path, _ = find_node_executable() 213 if not node_path: 214 return 1 215 216 extra_parameters = ["--loglevel=error"] 217 218 if no_optional: 219 extra_parameters.append("--no-optional") 220 221 package_lock_json_path = os.path.join(package_root, "package-lock.json") 222 223 if should_update: 224 cmd = [npm_path, "install"] 225 mozfileremove(package_lock_json_path) 226 else: 227 cmd = [npm_path, "ci"] 228 229 # On non-Windows, ensure npm is called via node, as node may not be in the 230 # path. 231 if platform.system() != "Windows": 232 cmd.insert(0, node_path) 233 234 cmd.extend(extra_parameters) 235 236 # Ensure that bare `node` and `npm` in scripts, including post-install scripts, finds the 237 # binary we're invoking with. Without this, it's easy for compiled extensions to get 238 # mismatched versions of the Node.js extension API. 239 path = os.environ.get("PATH", "").split(os.pathsep) 240 node_dir = os.path.dirname(node_path) 241 if node_dir not in path: 242 path = [node_dir] + path 243 244 if not skip_logging: 245 print( 246 'Installing %s for mach using "%s"...' % (package_name, " ".join(cmd)) 247 ) 248 result = call_process( 249 package_name, cmd, append_env={"PATH": os.pathsep.join(path)} 250 ) 251 252 if not result: 253 return 1 254 255 bin_path = os.path.join(package_root, "node_modules", ".bin", package_name) 256 257 if not skip_logging: 258 print("\n%s installed successfully!" % package_name) 259 print("\nNOTE: Your local %s binary is at %s\n" % (package_name, bin_path)) 260 261 finally: 262 os.chdir(orig_cwd) 263 264 265 def remove_directory(path, skip_logging=False): 266 if not skip_logging: 267 print("Clobbering %s..." % path) 268 if sys.platform.startswith("win") and have_winrm(): 269 process = subprocess.Popen(["winrm", "-rf", path]) 270 process.wait() 271 else: 272 mozfileremove(path) 273 274 275 def call_process(name, cmd, cwd=None, append_env={}): 276 env = dict(os.environ) 277 env.update(append_env) 278 279 try: 280 with open(os.devnull, "w") as fnull: 281 subprocess.check_call(cmd, cwd=cwd, stdout=fnull, env=env) 282 except subprocess.CalledProcessError: 283 if cwd: 284 print("\nError installing %s in the %s folder, aborting." % (name, cwd)) 285 else: 286 print("\nError installing %s, aborting." % name) 287 288 return False 289 290 return True 291 292 293 def have_winrm(): 294 # `winrm -h` should print 'winrm version ...' and exit 1 295 try: 296 p = subprocess.Popen( 297 ["winrm.exe", "-h"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT 298 ) 299 return p.wait() == 1 and p.stdout.read().startswith("winrm") 300 except Exception: 301 return False