xref: /OK3568_Linux_fs/yocto/poky/scripts/lib/recipetool/create_npm.py (revision 4882a59341e53eb6f0b4789bf948001014eff981)
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