| # Copyright (C) 2016 Intel Corporation |
| # Copyright (C) 2020 Savoir-Faire Linux |
| # |
| # SPDX-License-Identifier: GPL-2.0-only |
| # |
| """Recipe creation tool - npm module support plugin""" |
| |
| import json |
| import logging |
| import os |
| import re |
| import sys |
| import tempfile |
| import bb |
| from bb.fetch2.npm import NpmEnvironment |
| from bb.fetch2.npmsw import foreach_dependencies |
| from recipetool.create import RecipeHandler |
| from recipetool.create import get_license_md5sums |
| from recipetool.create import guess_license |
| from recipetool.create import split_pkg_licenses |
| logger = logging.getLogger('recipetool') |
| |
| TINFOIL = None |
| |
| def tinfoil_init(instance): |
| """Initialize tinfoil""" |
| global TINFOIL |
| TINFOIL = instance |
| |
| class NpmRecipeHandler(RecipeHandler): |
| """Class to handle the npm recipe creation""" |
| |
| @staticmethod |
| def _npm_name(name): |
| """Generate a Yocto friendly npm name""" |
| name = re.sub("/", "-", name) |
| name = name.lower() |
| name = re.sub(r"[^\-a-z0-9]", "", name) |
| name = name.strip("-") |
| return name |
| |
| @staticmethod |
| def _get_registry(lines): |
| """Get the registry value from the 'npm://registry' url""" |
| registry = None |
| |
| def _handle_registry(varname, origvalue, op, newlines): |
| nonlocal registry |
| if origvalue.startswith("npm://"): |
| registry = re.sub(r"^npm://", "http://", origvalue.split(";")[0]) |
| return origvalue, None, 0, True |
| |
| bb.utils.edit_metadata(lines, ["SRC_URI"], _handle_registry) |
| |
| return registry |
| |
| @staticmethod |
| def _ensure_npm(): |
| """Check if the 'npm' command is available in the recipes""" |
| if not TINFOIL.recipes_parsed: |
| TINFOIL.parse_recipes() |
| |
| try: |
| d = TINFOIL.parse_recipe("nodejs-native") |
| except bb.providers.NoProvider: |
| bb.error("Nothing provides 'nodejs-native' which is required for the build") |
| bb.note("You will likely need to add a layer that provides nodejs") |
| sys.exit(14) |
| |
| bindir = d.getVar("STAGING_BINDIR_NATIVE") |
| npmpath = os.path.join(bindir, "npm") |
| |
| if not os.path.exists(npmpath): |
| TINFOIL.build_targets("nodejs-native", "addto_recipe_sysroot") |
| |
| if not os.path.exists(npmpath): |
| bb.error("Failed to add 'npm' to sysroot") |
| sys.exit(14) |
| |
| return bindir |
| |
| @staticmethod |
| def _npm_global_configs(dev): |
| """Get the npm global configuration""" |
| configs = [] |
| |
| if dev: |
| configs.append(("also", "development")) |
| else: |
| configs.append(("only", "production")) |
| |
| configs.append(("save", "false")) |
| configs.append(("package-lock", "false")) |
| configs.append(("shrinkwrap", "false")) |
| return configs |
| |
| def _run_npm_install(self, d, srctree, registry, dev): |
| """Run the 'npm install' command without building the addons""" |
| configs = self._npm_global_configs(dev) |
| configs.append(("ignore-scripts", "true")) |
| |
| if registry: |
| configs.append(("registry", registry)) |
| |
| bb.utils.remove(os.path.join(srctree, "node_modules"), recurse=True) |
| |
| env = NpmEnvironment(d, configs=configs) |
| env.run("npm install", workdir=srctree) |
| |
| def _generate_shrinkwrap(self, d, srctree, dev): |
| """Check and generate the 'npm-shrinkwrap.json' file if needed""" |
| configs = self._npm_global_configs(dev) |
| |
| env = NpmEnvironment(d, configs=configs) |
| env.run("npm shrinkwrap", workdir=srctree) |
| |
| return os.path.join(srctree, "npm-shrinkwrap.json") |
| |
| def _handle_licenses(self, srctree, shrinkwrap_file, dev): |
| """Return the extra license files and the list of packages""" |
| licfiles = [] |
| packages = {} |
| |
| # Handle the parent package |
| packages["${PN}"] = "" |
| |
| def _licfiles_append_fallback_readme_files(destdir): |
| """Append README files as fallback to license files if a license files is missing""" |
| |
| fallback = True |
| readmes = [] |
| basedir = os.path.join(srctree, destdir) |
| for fn in os.listdir(basedir): |
| upper = fn.upper() |
| if upper.startswith("README"): |
| fullpath = os.path.join(basedir, fn) |
| readmes.append(fullpath) |
| if upper.startswith("COPYING") or "LICENCE" in upper or "LICENSE" in upper: |
| fallback = False |
| if fallback: |
| for readme in readmes: |
| licfiles.append(os.path.relpath(readme, srctree)) |
| |
| # Handle the dependencies |
| def _handle_dependency(name, params, deptree): |
| suffix = "-".join([self._npm_name(dep) for dep in deptree]) |
| destdirs = [os.path.join("node_modules", dep) for dep in deptree] |
| destdir = os.path.join(*destdirs) |
| packages["${PN}-" + suffix] = destdir |
| _licfiles_append_fallback_readme_files(destdir) |
| |
| with open(shrinkwrap_file, "r") as f: |
| shrinkwrap = json.load(f) |
| |
| foreach_dependencies(shrinkwrap, _handle_dependency, dev) |
| |
| return licfiles, packages |
| |
| def process(self, srctree, classes, lines_before, lines_after, handled, extravalues): |
| """Handle the npm recipe creation""" |
| |
| if "buildsystem" in handled: |
| return False |
| |
| files = RecipeHandler.checkfiles(srctree, ["package.json"]) |
| |
| if not files: |
| return False |
| |
| with open(files[0], "r") as f: |
| data = json.load(f) |
| |
| if "name" not in data or "version" not in data: |
| return False |
| |
| extravalues["PN"] = self._npm_name(data["name"]) |
| extravalues["PV"] = data["version"] |
| |
| if "description" in data: |
| extravalues["SUMMARY"] = data["description"] |
| |
| if "homepage" in data: |
| extravalues["HOMEPAGE"] = data["homepage"] |
| |
| dev = bb.utils.to_boolean(str(extravalues.get("NPM_INSTALL_DEV", "0")), False) |
| registry = self._get_registry(lines_before) |
| |
| bb.note("Checking if npm is available ...") |
| # The native npm is used here (and not the host one) to ensure that the |
| # npm version is high enough to ensure an efficient dependency tree |
| # resolution and avoid issue with the shrinkwrap file format. |
| # Moreover the native npm is mandatory for the build. |
| bindir = self._ensure_npm() |
| |
| d = bb.data.createCopy(TINFOIL.config_data) |
| d.prependVar("PATH", bindir + ":") |
| d.setVar("S", srctree) |
| |
| bb.note("Generating shrinkwrap file ...") |
| # To generate the shrinkwrap file the dependencies have to be installed |
| # first. During the generation process some files may be updated / |
| # deleted. By default devtool tracks the diffs in the srctree and raises |
| # errors when finishing the recipe if some diffs are found. |
| git_exclude_file = os.path.join(srctree, ".git", "info", "exclude") |
| if os.path.exists(git_exclude_file): |
| with open(git_exclude_file, "r+") as f: |
| lines = f.readlines() |
| for line in ["/node_modules/", "/npm-shrinkwrap.json"]: |
| if line not in lines: |
| f.write(line + "\n") |
| |
| lock_file = os.path.join(srctree, "package-lock.json") |
| lock_copy = lock_file + ".copy" |
| if os.path.exists(lock_file): |
| bb.utils.copyfile(lock_file, lock_copy) |
| |
| self._run_npm_install(d, srctree, registry, dev) |
| shrinkwrap_file = self._generate_shrinkwrap(d, srctree, dev) |
| |
| with open(shrinkwrap_file, "r") as f: |
| shrinkwrap = json.load(f) |
| |
| if os.path.exists(lock_copy): |
| bb.utils.movefile(lock_copy, lock_file) |
| |
| # Add the shrinkwrap file as 'extrafiles' |
| shrinkwrap_copy = shrinkwrap_file + ".copy" |
| bb.utils.copyfile(shrinkwrap_file, shrinkwrap_copy) |
| extravalues.setdefault("extrafiles", {}) |
| extravalues["extrafiles"]["npm-shrinkwrap.json"] = shrinkwrap_copy |
| |
| url_local = "npmsw://%s" % shrinkwrap_file |
| url_recipe= "npmsw://${THISDIR}/${BPN}/npm-shrinkwrap.json" |
| |
| if dev: |
| url_local += ";dev=1" |
| url_recipe += ";dev=1" |
| |
| # Add the npmsw url in the SRC_URI of the generated recipe |
| def _handle_srcuri(varname, origvalue, op, newlines): |
| """Update the version value and add the 'npmsw://' url""" |
| value = origvalue.replace("version=" + data["version"], "version=${PV}") |
| value = value.replace("version=latest", "version=${PV}") |
| values = [line.strip() for line in value.strip('\n').splitlines()] |
| if "dependencies" in shrinkwrap: |
| values.append(url_recipe) |
| return values, None, 4, False |
| |
| (_, newlines) = bb.utils.edit_metadata(lines_before, ["SRC_URI"], _handle_srcuri) |
| lines_before[:] = [line.rstrip('\n') for line in newlines] |
| |
| # In order to generate correct licence checksums in the recipe the |
| # dependencies have to be fetched again using the npmsw url |
| bb.note("Fetching npm dependencies ...") |
| bb.utils.remove(os.path.join(srctree, "node_modules"), recurse=True) |
| fetcher = bb.fetch2.Fetch([url_local], d) |
| fetcher.download() |
| fetcher.unpack(srctree) |
| |
| bb.note("Handling licences ...") |
| (licfiles, packages) = self._handle_licenses(srctree, shrinkwrap_file, dev) |
| |
| def _guess_odd_license(licfiles): |
| import bb |
| |
| md5sums = get_license_md5sums(d, linenumbers=True) |
| |
| chksums = [] |
| licenses = [] |
| for licfile in licfiles: |
| f = os.path.join(srctree, licfile) |
| md5value = bb.utils.md5_file(f) |
| (license, beginline, endline, md5) = md5sums.get(md5value, |
| (None, "", "", "")) |
| if not license: |
| license = "Unknown" |
| logger.info("Please add the following line for '%s' to a " |
| "'lib/recipetool/licenses.csv' and replace `Unknown`, " |
| "`X`, `Y` and `MD5` with the license, begin line, " |
| "end line and partial MD5 checksum:\n" \ |
| "%s,Unknown,X,Y,MD5" % (licfile, md5value)) |
| chksums.append("file://%s%s%s;md5=%s" % (licfile, |
| ";beginline=%s" % (beginline) if beginline else "", |
| ";endline=%s" % (endline) if endline else "", |
| md5 if md5 else md5value)) |
| licenses.append((license, licfile, md5value)) |
| return (licenses, chksums) |
| |
| (licenses, extravalues["LIC_FILES_CHKSUM"]) = _guess_odd_license(licfiles) |
| split_pkg_licenses([*licenses, *guess_license(srctree, d)], packages, lines_after) |
| |
| classes.append("npm") |
| handled.append("buildsystem") |
| |
| return True |
| |
| def register_recipe_handlers(handlers): |
| """Register the npm handler""" |
| handlers.append((NpmRecipeHandler(), 60)) |