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