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