# 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 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 guess_license from recipetool.create import split_pkg_licenses 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 = {} def _licfiles_append(licfile): """Append 'licfile' to the license files list""" licfilepath = os.path.join(srctree, licfile) licmd5 = bb.utils.md5_file(licfilepath) licfiles.append("file://%s;md5=%s" % (licfile, licmd5)) # Handle the parent package _licfiles_append("package.json") packages["${PN}"] = "" # 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) _licfiles_append(os.path.join(destdir, "package.json")) packages["${PN}-" + suffix] = 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) 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()] 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) extravalues["LIC_FILES_CHKSUM"] = licfiles split_pkg_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))