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