# 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))