1# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*- 2# vim: set filetype=python: 3# This Source Code Form is subject to the terms of the Mozilla Public 4# License, v. 2.0. If a copy of the MPL was not distributed with this 5# file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 7import json 8import os 9import platform 10import re 11import subprocess 12import sys 13from distutils.version import LooseVersion 14from filecmp import dircmp 15 16from mozbuild.nodeutil import ( 17 find_node_executable, 18 find_npm_executable, 19 NPM_MIN_VERSION, 20 NODE_MIN_VERSION, 21) 22from mozfile.mozfile import remove as mozfileremove 23 24 25NODE_MACHING_VERSION_NOT_FOUND_MESSAGE = """ 26Could not find Node.js executable later than %s. 27 28Executing `mach bootstrap --no-system-changes` should 29install a compatible version in ~/.mozbuild on most platforms. 30""".strip() 31 32NPM_MACHING_VERSION_NOT_FOUND_MESSAGE = """ 33Could not find npm executable later than %s. 34 35Executing `mach bootstrap --no-system-changes` should 36install a compatible version in ~/.mozbuild on most platforms. 37""".strip() 38 39NODE_NOT_FOUND_MESSAGE = """ 40nodejs is either not installed or is installed to a non-standard path. 41 42Executing `mach bootstrap --no-system-changes` should 43install a compatible version in ~/.mozbuild on most platforms. 44""".strip() 45 46NPM_NOT_FOUND_MESSAGE = """ 47Node Package Manager (npm) is either not installed or installed to a 48non-standard path. 49 50Executing `mach bootstrap --no-system-changes` should 51install a compatible version in ~/.mozbuild on most platforms. 52""".strip() 53 54 55VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") 56CARET_VERSION_RANGE_RE = re.compile(r"^\^((\d+)\.\d+\.\d+)$") 57 58project_root = None 59 60 61def eslint_maybe_setup(): 62 """Setup ESLint only if it is needed.""" 63 has_issues, needs_clobber = eslint_module_needs_setup() 64 65 if has_issues: 66 eslint_setup(needs_clobber) 67 68 69def eslint_setup(should_clobber=False): 70 """Ensure eslint is optimally configured. 71 72 This command will inspect your eslint configuration and 73 guide you through an interactive wizard helping you configure 74 eslint for optimal use on Mozilla projects. 75 """ 76 package_setup(get_project_root(), "eslint", should_clobber=should_clobber) 77 78 79def package_setup( 80 package_root, 81 package_name, 82 should_update=False, 83 should_clobber=False, 84 no_optional=False, 85): 86 """Ensure `package_name` at `package_root` is installed. 87 88 When `should_update` is true, clobber, install, and produce a new 89 "package-lock.json" file. 90 91 This populates `package_root/node_modules`. 92 93 """ 94 orig_project_root = get_project_root() 95 orig_cwd = os.getcwd() 96 97 if should_update: 98 should_clobber = True 99 100 try: 101 set_project_root(package_root) 102 sys.path.append(os.path.dirname(__file__)) 103 104 # npm sometimes fails to respect cwd when it is run using check_call so 105 # we manually switch folders here instead. 106 project_root = get_project_root() 107 os.chdir(project_root) 108 109 if should_clobber: 110 node_modules_path = os.path.join(project_root, "node_modules") 111 print("Clobbering %s..." % node_modules_path) 112 if sys.platform.startswith("win") and have_winrm(): 113 process = subprocess.Popen(["winrm", "-rf", node_modules_path]) 114 process.wait() 115 else: 116 mozfileremove(node_modules_path) 117 118 npm_path, _ = find_npm_executable() 119 if not npm_path: 120 return 1 121 122 node_path, _ = find_node_executable() 123 if not node_path: 124 return 1 125 126 extra_parameters = ["--loglevel=error"] 127 128 if no_optional: 129 extra_parameters.append("--no-optional") 130 131 package_lock_json_path = os.path.join(get_project_root(), "package-lock.json") 132 133 if should_update: 134 cmd = [npm_path, "install"] 135 mozfileremove(package_lock_json_path) 136 else: 137 cmd = [npm_path, "ci"] 138 139 # On non-Windows, ensure npm is called via node, as node may not be in the 140 # path. 141 if platform.system() != "Windows": 142 cmd.insert(0, node_path) 143 144 # Ensure that bare `node` and `npm` in scripts, including post-install scripts, finds the 145 # binary we're invoking with. Without this, it's easy for compiled extensions to get 146 # mismatched versions of the Node.js extension API. 147 extra_parameters.append("--scripts-prepend-node-path") 148 149 cmd.extend(extra_parameters) 150 151 print('Installing %s for mach using "%s"...' % (package_name, " ".join(cmd))) 152 result = call_process(package_name, cmd) 153 154 if not result: 155 return 1 156 157 bin_path = os.path.join( 158 get_project_root(), "node_modules", ".bin", package_name 159 ) 160 161 print("\n%s installed successfully!" % package_name) 162 print("\nNOTE: Your local %s binary is at %s\n" % (package_name, bin_path)) 163 164 finally: 165 set_project_root(orig_project_root) 166 os.chdir(orig_cwd) 167 168 169def call_process(name, cmd, cwd=None): 170 env = dict(os.environ) 171 172 try: 173 with open(os.devnull, "w") as fnull: 174 subprocess.check_call(cmd, cwd=cwd, stdout=fnull, env=env) 175 except subprocess.CalledProcessError: 176 if cwd: 177 print("\nError installing %s in the %s folder, aborting." % (name, cwd)) 178 else: 179 print("\nError installing %s, aborting." % name) 180 181 return False 182 183 return True 184 185 186def expected_eslint_modules(): 187 # Read the expected version of ESLint and external modules 188 expected_modules_path = os.path.join(get_project_root(), "package.json") 189 with open(expected_modules_path, "r", encoding="utf-8") as f: 190 sections = json.load(f) 191 expected_modules = sections.get("dependencies", {}) 192 expected_modules.update(sections.get("devDependencies", {})) 193 194 # Also read the in-tree ESLint plugin mozilla information, to ensure the 195 # dependencies are up to date. 196 mozilla_json_path = os.path.join( 197 get_eslint_module_path(), "eslint-plugin-mozilla", "package.json" 198 ) 199 with open(mozilla_json_path, "r", encoding="utf-8") as f: 200 expected_modules.update(json.load(f).get("dependencies", {})) 201 202 # Also read the in-tree ESLint plugin spidermonkey information, to ensure the 203 # dependencies are up to date. 204 mozilla_json_path = os.path.join( 205 get_eslint_module_path(), "eslint-plugin-spidermonkey-js", "package.json" 206 ) 207 with open(mozilla_json_path, "r", encoding="utf-8") as f: 208 expected_modules.update(json.load(f).get("dependencies", {})) 209 210 return expected_modules 211 212 213def check_eslint_files(node_modules_path, name): 214 def check_file_diffs(dcmp): 215 # Diff files only looks at files that are different. Not for files 216 # that are only present on one side. This should be generally OK as 217 # new files will need to be added in the index.js for the package. 218 if dcmp.diff_files and dcmp.diff_files != ["package.json"]: 219 return True 220 221 result = False 222 223 # Again, we only look at common sub directories for the same reason 224 # as above. 225 for sub_dcmp in dcmp.subdirs.values(): 226 result = result or check_file_diffs(sub_dcmp) 227 228 return result 229 230 dcmp = dircmp( 231 os.path.join(node_modules_path, name), 232 os.path.join(get_eslint_module_path(), name), 233 ) 234 235 return check_file_diffs(dcmp) 236 237 238def eslint_module_needs_setup(): 239 has_issues = False 240 needs_clobber = False 241 node_modules_path = os.path.join(get_project_root(), "node_modules") 242 243 for name, expected_data in expected_eslint_modules().items(): 244 # expected_eslint_modules returns a string for the version number of 245 # dependencies for installation of eslint generally, and an object 246 # for our in-tree plugins (which contains the entire module info). 247 if "version" in expected_data: 248 version_range = expected_data["version"] 249 else: 250 version_range = expected_data 251 252 path = os.path.join(node_modules_path, name, "package.json") 253 254 if not os.path.exists(path): 255 print("%s v%s needs to be installed locally." % (name, version_range)) 256 has_issues = True 257 continue 258 data = json.load(open(path, encoding="utf-8")) 259 260 if version_range.startswith("file:"): 261 # We don't need to check local file installations for versions, as 262 # these are symlinked, so we'll always pick up the latest. 263 continue 264 265 if name == "eslint" and LooseVersion("4.0.0") > LooseVersion(data["version"]): 266 print("ESLint is an old version, clobbering node_modules directory") 267 needs_clobber = True 268 has_issues = True 269 continue 270 271 if not version_in_range(data["version"], version_range): 272 print("%s v%s should be v%s." % (name, data["version"], version_range)) 273 has_issues = True 274 continue 275 276 return has_issues, needs_clobber 277 278 279def version_in_range(version, version_range): 280 """ 281 Check if a module version is inside a version range. Only supports explicit versions and 282 caret ranges for the moment, since that's all we've used so far. 283 """ 284 if version == version_range: 285 return True 286 287 version_match = VERSION_RE.match(version) 288 if not version_match: 289 raise RuntimeError("mach eslint doesn't understand module version %s" % version) 290 version = LooseVersion(version) 291 292 # Caret ranges as specified by npm allow changes that do not modify the left-most non-zero 293 # digit in the [major, minor, patch] tuple. The code below assumes the major digit is 294 # non-zero. 295 range_match = CARET_VERSION_RANGE_RE.match(version_range) 296 if range_match: 297 range_version = range_match.group(1) 298 range_major = int(range_match.group(2)) 299 300 range_min = LooseVersion(range_version) 301 range_max = LooseVersion("%d.0.0" % (range_major + 1)) 302 303 return range_min <= version < range_max 304 305 return False 306 307 308def get_possible_node_paths_win(): 309 """ 310 Return possible nodejs paths on Windows. 311 """ 312 if platform.system() != "Windows": 313 return [] 314 315 return list( 316 { 317 "%s\\nodejs" % os.environ.get("SystemDrive"), 318 os.path.join(os.environ.get("ProgramFiles"), "nodejs"), 319 os.path.join(os.environ.get("PROGRAMW6432"), "nodejs"), 320 os.path.join(os.environ.get("PROGRAMFILES"), "nodejs"), 321 } 322 ) 323 324 325def get_version(path): 326 try: 327 version_str = subprocess.check_output( 328 [path, "--version"], stderr=subprocess.STDOUT, universal_newlines=True 329 ) 330 return version_str 331 except (subprocess.CalledProcessError, OSError): 332 return None 333 334 335def set_project_root(root=None): 336 """Sets the project root to the supplied path, or works out what the root 337 is based on looking for 'mach'. 338 339 Keyword arguments: 340 root - (optional) The path to set the root to. 341 """ 342 global project_root 343 344 if root: 345 project_root = root 346 return 347 348 file_found = False 349 folder = os.getcwd() 350 351 while folder: 352 if os.path.exists(os.path.join(folder, "mach")): 353 file_found = True 354 break 355 else: 356 folder = os.path.dirname(folder) 357 358 if file_found: 359 project_root = os.path.abspath(folder) 360 361 362def get_project_root(): 363 """Returns the absolute path to the root of the project, see set_project_root() 364 for how this is determined. 365 """ 366 global project_root 367 368 if not project_root: 369 set_project_root() 370 371 return project_root 372 373 374def get_eslint_module_path(): 375 return os.path.join(get_project_root(), "tools", "lint", "eslint") 376 377 378def check_node_executables_valid(): 379 node_path, version = find_node_executable() 380 if not node_path: 381 print(NODE_NOT_FOUND_MESSAGE) 382 return False 383 if not version: 384 print(NODE_MACHING_VERSION_NOT_FOUND_MESSAGE % NODE_MIN_VERSION) 385 return False 386 387 npm_path, version = find_npm_executable() 388 if not npm_path: 389 print(NPM_NOT_FOUND_MESSAGE) 390 return False 391 if not version: 392 print(NPM_MACHING_VERSION_NOT_FOUND_MESSAGE % NPM_MIN_VERSION) 393 return False 394 395 return True 396 397 398def have_winrm(): 399 # `winrm -h` should print 'winrm version ...' and exit 1 400 try: 401 p = subprocess.Popen( 402 ["winrm.exe", "-h"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT 403 ) 404 return p.wait() == 1 and p.stdout.read().startswith("winrm") 405 except Exception: 406 return False 407