Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 1 | # Copyright (C) 2016 Intel Corporation |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 2 | # Copyright (C) 2020 Savoir-Faire Linux |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 3 | # |
Brad Bishop | c342db3 | 2019-05-15 21:57:59 -0400 | [diff] [blame] | 4 | # SPDX-License-Identifier: GPL-2.0-only |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 5 | # |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 6 | """Recipe creation tool - npm module support plugin""" |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 7 | |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 8 | import json |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 9 | import logging |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 10 | import os |
| 11 | import re |
| 12 | import sys |
| 13 | import tempfile |
| 14 | import bb |
| 15 | from bb.fetch2.npm import NpmEnvironment |
Andrew Geissler | 8f84068 | 2023-07-21 09:09:43 -0500 | [diff] [blame] | 16 | from bb.fetch2.npm import npm_package |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 17 | from bb.fetch2.npmsw import foreach_dependencies |
| 18 | from recipetool.create import RecipeHandler |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 19 | from recipetool.create import get_license_md5sums |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 20 | from recipetool.create import guess_license |
| 21 | from recipetool.create import split_pkg_licenses |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 22 | logger = logging.getLogger('recipetool') |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 23 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 24 | TINFOIL = None |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 25 | |
| 26 | def tinfoil_init(instance): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 27 | """Initialize tinfoil""" |
| 28 | global TINFOIL |
| 29 | TINFOIL = instance |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 30 | |
| 31 | class NpmRecipeHandler(RecipeHandler): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 32 | """Class to handle the npm recipe creation""" |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 33 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 34 | @staticmethod |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 35 | def _get_registry(lines): |
| 36 | """Get the registry value from the 'npm://registry' url""" |
| 37 | registry = None |
| 38 | |
| 39 | def _handle_registry(varname, origvalue, op, newlines): |
| 40 | nonlocal registry |
| 41 | if origvalue.startswith("npm://"): |
| 42 | registry = re.sub(r"^npm://", "http://", origvalue.split(";")[0]) |
| 43 | return origvalue, None, 0, True |
| 44 | |
| 45 | bb.utils.edit_metadata(lines, ["SRC_URI"], _handle_registry) |
| 46 | |
| 47 | return registry |
| 48 | |
| 49 | @staticmethod |
| 50 | def _ensure_npm(): |
| 51 | """Check if the 'npm' command is available in the recipes""" |
| 52 | if not TINFOIL.recipes_parsed: |
| 53 | TINFOIL.parse_recipes() |
| 54 | |
Brad Bishop | d7bf8c1 | 2018-02-25 22:55:05 -0500 | [diff] [blame] | 55 | try: |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 56 | d = TINFOIL.parse_recipe("nodejs-native") |
Brad Bishop | d7bf8c1 | 2018-02-25 22:55:05 -0500 | [diff] [blame] | 57 | except bb.providers.NoProvider: |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 58 | bb.error("Nothing provides 'nodejs-native' which is required for the build") |
| 59 | bb.note("You will likely need to add a layer that provides nodejs") |
| 60 | sys.exit(14) |
| 61 | |
| 62 | bindir = d.getVar("STAGING_BINDIR_NATIVE") |
| 63 | npmpath = os.path.join(bindir, "npm") |
| 64 | |
Brad Bishop | d7bf8c1 | 2018-02-25 22:55:05 -0500 | [diff] [blame] | 65 | if not os.path.exists(npmpath): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 66 | TINFOIL.build_targets("nodejs-native", "addto_recipe_sysroot") |
| 67 | |
Brad Bishop | d7bf8c1 | 2018-02-25 22:55:05 -0500 | [diff] [blame] | 68 | if not os.path.exists(npmpath): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 69 | bb.error("Failed to add 'npm' to sysroot") |
| 70 | sys.exit(14) |
| 71 | |
Brad Bishop | d7bf8c1 | 2018-02-25 22:55:05 -0500 | [diff] [blame] | 72 | return bindir |
| 73 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 74 | @staticmethod |
| 75 | def _npm_global_configs(dev): |
| 76 | """Get the npm global configuration""" |
| 77 | configs = [] |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 78 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 79 | if dev: |
| 80 | configs.append(("also", "development")) |
| 81 | else: |
| 82 | configs.append(("only", "production")) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 83 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 84 | configs.append(("save", "false")) |
| 85 | configs.append(("package-lock", "false")) |
| 86 | configs.append(("shrinkwrap", "false")) |
| 87 | return configs |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 88 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 89 | def _run_npm_install(self, d, srctree, registry, dev): |
| 90 | """Run the 'npm install' command without building the addons""" |
| 91 | configs = self._npm_global_configs(dev) |
| 92 | configs.append(("ignore-scripts", "true")) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 93 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 94 | if registry: |
| 95 | configs.append(("registry", registry)) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 96 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 97 | bb.utils.remove(os.path.join(srctree, "node_modules"), recurse=True) |
Brad Bishop | 6e60e8b | 2018-02-01 10:27:11 -0500 | [diff] [blame] | 98 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 99 | env = NpmEnvironment(d, configs=configs) |
| 100 | env.run("npm install", workdir=srctree) |
| 101 | |
| 102 | def _generate_shrinkwrap(self, d, srctree, dev): |
| 103 | """Check and generate the 'npm-shrinkwrap.json' file if needed""" |
| 104 | configs = self._npm_global_configs(dev) |
| 105 | |
| 106 | env = NpmEnvironment(d, configs=configs) |
| 107 | env.run("npm shrinkwrap", workdir=srctree) |
| 108 | |
| 109 | return os.path.join(srctree, "npm-shrinkwrap.json") |
| 110 | |
| 111 | def _handle_licenses(self, srctree, shrinkwrap_file, dev): |
| 112 | """Return the extra license files and the list of packages""" |
| 113 | licfiles = [] |
| 114 | packages = {} |
| 115 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 116 | # Handle the parent package |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 117 | packages["${PN}"] = "" |
| 118 | |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 119 | def _licfiles_append_fallback_readme_files(destdir): |
| 120 | """Append README files as fallback to license files if a license files is missing""" |
| 121 | |
| 122 | fallback = True |
| 123 | readmes = [] |
| 124 | basedir = os.path.join(srctree, destdir) |
| 125 | for fn in os.listdir(basedir): |
| 126 | upper = fn.upper() |
| 127 | if upper.startswith("README"): |
| 128 | fullpath = os.path.join(basedir, fn) |
| 129 | readmes.append(fullpath) |
| 130 | if upper.startswith("COPYING") or "LICENCE" in upper or "LICENSE" in upper: |
| 131 | fallback = False |
| 132 | if fallback: |
| 133 | for readme in readmes: |
| 134 | licfiles.append(os.path.relpath(readme, srctree)) |
| 135 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 136 | # Handle the dependencies |
Andrew Geissler | 8f84068 | 2023-07-21 09:09:43 -0500 | [diff] [blame] | 137 | def _handle_dependency(name, params, destdir): |
| 138 | deptree = destdir.split('node_modules/') |
| 139 | suffix = "-".join([npm_package(dep) for dep in deptree]) |
| 140 | packages["${PN}" + suffix] = destdir |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 141 | _licfiles_append_fallback_readme_files(destdir) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 142 | |
| 143 | with open(shrinkwrap_file, "r") as f: |
| 144 | shrinkwrap = json.load(f) |
| 145 | |
| 146 | foreach_dependencies(shrinkwrap, _handle_dependency, dev) |
| 147 | |
| 148 | return licfiles, packages |
Andrew Geissler | 8f84068 | 2023-07-21 09:09:43 -0500 | [diff] [blame] | 149 | |
| 150 | # Handle the peer dependencies |
| 151 | def _handle_peer_dependency(self, shrinkwrap_file): |
| 152 | """Check if package has peer dependencies and show warning if it is the case""" |
| 153 | with open(shrinkwrap_file, "r") as f: |
| 154 | shrinkwrap = json.load(f) |
| 155 | |
| 156 | packages = shrinkwrap.get("packages", {}) |
| 157 | peer_deps = packages.get("", {}).get("peerDependencies", {}) |
| 158 | |
| 159 | for peer_dep in peer_deps: |
| 160 | peer_dep_yocto_name = npm_package(peer_dep) |
| 161 | bb.warn(peer_dep + " is a peer dependencie of the actual package. " + |
| 162 | "Please add this peer dependencie to the RDEPENDS variable as %s and generate its recipe with devtool" |
| 163 | % peer_dep_yocto_name) |
| 164 | |
| 165 | |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 166 | |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 167 | def process(self, srctree, classes, lines_before, lines_after, handled, extravalues): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 168 | """Handle the npm recipe creation""" |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 169 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 170 | if "buildsystem" in handled: |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 171 | return False |
| 172 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 173 | files = RecipeHandler.checkfiles(srctree, ["package.json"]) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 174 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 175 | if not files: |
| 176 | return False |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 177 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 178 | with open(files[0], "r") as f: |
| 179 | data = json.load(f) |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 180 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 181 | if "name" not in data or "version" not in data: |
| 182 | return False |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 183 | |
Andrew Geissler | 8f84068 | 2023-07-21 09:09:43 -0500 | [diff] [blame] | 184 | extravalues["PN"] = npm_package(data["name"]) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 185 | extravalues["PV"] = data["version"] |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 186 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 187 | if "description" in data: |
| 188 | extravalues["SUMMARY"] = data["description"] |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 189 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 190 | if "homepage" in data: |
| 191 | extravalues["HOMEPAGE"] = data["homepage"] |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 192 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 193 | dev = bb.utils.to_boolean(str(extravalues.get("NPM_INSTALL_DEV", "0")), False) |
| 194 | registry = self._get_registry(lines_before) |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 195 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 196 | bb.note("Checking if npm is available ...") |
| 197 | # The native npm is used here (and not the host one) to ensure that the |
| 198 | # npm version is high enough to ensure an efficient dependency tree |
| 199 | # resolution and avoid issue with the shrinkwrap file format. |
| 200 | # Moreover the native npm is mandatory for the build. |
| 201 | bindir = self._ensure_npm() |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 202 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 203 | d = bb.data.createCopy(TINFOIL.config_data) |
| 204 | d.prependVar("PATH", bindir + ":") |
| 205 | d.setVar("S", srctree) |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 206 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 207 | bb.note("Generating shrinkwrap file ...") |
| 208 | # To generate the shrinkwrap file the dependencies have to be installed |
| 209 | # first. During the generation process some files may be updated / |
| 210 | # deleted. By default devtool tracks the diffs in the srctree and raises |
| 211 | # errors when finishing the recipe if some diffs are found. |
| 212 | git_exclude_file = os.path.join(srctree, ".git", "info", "exclude") |
| 213 | if os.path.exists(git_exclude_file): |
| 214 | with open(git_exclude_file, "r+") as f: |
| 215 | lines = f.readlines() |
| 216 | for line in ["/node_modules/", "/npm-shrinkwrap.json"]: |
| 217 | if line not in lines: |
| 218 | f.write(line + "\n") |
Patrick Williams | c0f7c04 | 2017-02-23 20:41:17 -0600 | [diff] [blame] | 219 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 220 | lock_file = os.path.join(srctree, "package-lock.json") |
| 221 | lock_copy = lock_file + ".copy" |
| 222 | if os.path.exists(lock_file): |
| 223 | bb.utils.copyfile(lock_file, lock_copy) |
Brad Bishop | 6e60e8b | 2018-02-01 10:27:11 -0500 | [diff] [blame] | 224 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 225 | self._run_npm_install(d, srctree, registry, dev) |
| 226 | shrinkwrap_file = self._generate_shrinkwrap(d, srctree, dev) |
Brad Bishop | 6e60e8b | 2018-02-01 10:27:11 -0500 | [diff] [blame] | 227 | |
Andrew Geissler | d1e8949 | 2021-02-12 15:35:20 -0600 | [diff] [blame] | 228 | with open(shrinkwrap_file, "r") as f: |
| 229 | shrinkwrap = json.load(f) |
| 230 | |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 231 | if os.path.exists(lock_copy): |
| 232 | bb.utils.movefile(lock_copy, lock_file) |
| 233 | |
| 234 | # Add the shrinkwrap file as 'extrafiles' |
| 235 | shrinkwrap_copy = shrinkwrap_file + ".copy" |
| 236 | bb.utils.copyfile(shrinkwrap_file, shrinkwrap_copy) |
| 237 | extravalues.setdefault("extrafiles", {}) |
| 238 | extravalues["extrafiles"]["npm-shrinkwrap.json"] = shrinkwrap_copy |
| 239 | |
| 240 | url_local = "npmsw://%s" % shrinkwrap_file |
| 241 | url_recipe= "npmsw://${THISDIR}/${BPN}/npm-shrinkwrap.json" |
| 242 | |
| 243 | if dev: |
| 244 | url_local += ";dev=1" |
| 245 | url_recipe += ";dev=1" |
| 246 | |
| 247 | # Add the npmsw url in the SRC_URI of the generated recipe |
| 248 | def _handle_srcuri(varname, origvalue, op, newlines): |
| 249 | """Update the version value and add the 'npmsw://' url""" |
| 250 | value = origvalue.replace("version=" + data["version"], "version=${PV}") |
| 251 | value = value.replace("version=latest", "version=${PV}") |
| 252 | values = [line.strip() for line in value.strip('\n').splitlines()] |
Andrew Geissler | 8f84068 | 2023-07-21 09:09:43 -0500 | [diff] [blame] | 253 | if "dependencies" in shrinkwrap.get("packages", {}).get("", {}): |
Andrew Geissler | d1e8949 | 2021-02-12 15:35:20 -0600 | [diff] [blame] | 254 | values.append(url_recipe) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 255 | return values, None, 4, False |
| 256 | |
| 257 | (_, newlines) = bb.utils.edit_metadata(lines_before, ["SRC_URI"], _handle_srcuri) |
| 258 | lines_before[:] = [line.rstrip('\n') for line in newlines] |
| 259 | |
| 260 | # In order to generate correct licence checksums in the recipe the |
| 261 | # dependencies have to be fetched again using the npmsw url |
| 262 | bb.note("Fetching npm dependencies ...") |
| 263 | bb.utils.remove(os.path.join(srctree, "node_modules"), recurse=True) |
| 264 | fetcher = bb.fetch2.Fetch([url_local], d) |
| 265 | fetcher.download() |
| 266 | fetcher.unpack(srctree) |
| 267 | |
| 268 | bb.note("Handling licences ...") |
| 269 | (licfiles, packages) = self._handle_licenses(srctree, shrinkwrap_file, dev) |
Andrew Geissler | eff2747 | 2021-10-29 15:35:00 -0500 | [diff] [blame] | 270 | |
| 271 | def _guess_odd_license(licfiles): |
| 272 | import bb |
| 273 | |
| 274 | md5sums = get_license_md5sums(d, linenumbers=True) |
| 275 | |
| 276 | chksums = [] |
| 277 | licenses = [] |
| 278 | for licfile in licfiles: |
| 279 | f = os.path.join(srctree, licfile) |
| 280 | md5value = bb.utils.md5_file(f) |
| 281 | (license, beginline, endline, md5) = md5sums.get(md5value, |
| 282 | (None, "", "", "")) |
| 283 | if not license: |
| 284 | license = "Unknown" |
| 285 | logger.info("Please add the following line for '%s' to a " |
| 286 | "'lib/recipetool/licenses.csv' and replace `Unknown`, " |
| 287 | "`X`, `Y` and `MD5` with the license, begin line, " |
| 288 | "end line and partial MD5 checksum:\n" \ |
| 289 | "%s,Unknown,X,Y,MD5" % (licfile, md5value)) |
| 290 | chksums.append("file://%s%s%s;md5=%s" % (licfile, |
| 291 | ";beginline=%s" % (beginline) if beginline else "", |
| 292 | ";endline=%s" % (endline) if endline else "", |
| 293 | md5 if md5 else md5value)) |
| 294 | licenses.append((license, licfile, md5value)) |
| 295 | return (licenses, chksums) |
| 296 | |
| 297 | (licenses, extravalues["LIC_FILES_CHKSUM"]) = _guess_odd_license(licfiles) |
| 298 | split_pkg_licenses([*licenses, *guess_license(srctree, d)], packages, lines_after) |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 299 | |
| 300 | classes.append("npm") |
| 301 | handled.append("buildsystem") |
| 302 | |
Andrew Geissler | 8f84068 | 2023-07-21 09:09:43 -0500 | [diff] [blame] | 303 | # Check if package has peer dependencies and inform the user |
| 304 | self._handle_peer_dependency(shrinkwrap_file) |
| 305 | |
Brad Bishop | 6e60e8b | 2018-02-01 10:27:11 -0500 | [diff] [blame] | 306 | return True |
| 307 | |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 308 | def register_recipe_handlers(handlers): |
Andrew Geissler | 82c905d | 2020-04-13 13:39:40 -0500 | [diff] [blame] | 309 | """Register the npm handler""" |
Patrick Williams | d8c66bc | 2016-06-20 12:57:21 -0500 | [diff] [blame] | 310 | handlers.append((NpmRecipeHandler(), 60)) |