blob: 39429ebad3cc0561ff64b4a4aad640aee4570504 [file] [log] [blame]
Patrick Williamsd8c66bc2016-06-20 12:57:21 -05001# Recipe creation tool - node.js NPM module support plugin
2#
3# Copyright (C) 2016 Intel Corporation
4#
Brad Bishopc342db32019-05-15 21:57:59 -04005# SPDX-License-Identifier: GPL-2.0-only
Patrick Williamsd8c66bc2016-06-20 12:57:21 -05006#
Patrick Williamsd8c66bc2016-06-20 12:57:21 -05007
8import os
Brad Bishopc342db32019-05-15 21:57:59 -04009import sys
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050010import logging
11import subprocess
12import tempfile
13import shutil
14import json
Brad Bishopd7bf8c12018-02-25 22:55:05 -050015from recipetool.create import RecipeHandler, split_pkg_licenses, handle_license_vars
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050016
17logger = logging.getLogger('recipetool')
18
19
20tinfoil = None
21
22def tinfoil_init(instance):
23 global tinfoil
24 tinfoil = instance
25
26
27class NpmRecipeHandler(RecipeHandler):
28 lockdownpath = None
29
Brad Bishopd7bf8c12018-02-25 22:55:05 -050030 def _ensure_npm(self, fixed_setup=False):
31 if not tinfoil.recipes_parsed:
32 tinfoil.parse_recipes()
33 try:
34 rd = tinfoil.parse_recipe('nodejs-native')
35 except bb.providers.NoProvider:
36 if fixed_setup:
37 msg = 'nodejs-native is required for npm but is not available within this SDK'
38 else:
39 msg = 'nodejs-native is required for npm but is not available - you will likely need to add a layer that provides nodejs'
40 logger.error(msg)
41 return None
42 bindir = rd.getVar('STAGING_BINDIR_NATIVE')
43 npmpath = os.path.join(bindir, 'npm')
44 if not os.path.exists(npmpath):
45 tinfoil.build_targets('nodejs-native', 'addto_recipe_sysroot')
46 if not os.path.exists(npmpath):
47 logger.error('npm required to process specified source, but nodejs-native did not seem to populate it')
48 return None
49 return bindir
50
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050051 def _handle_license(self, data):
52 '''
53 Handle the license value from an npm package.json file
54 '''
55 license = None
56 if 'license' in data:
57 license = data['license']
58 if isinstance(license, dict):
59 license = license.get('type', None)
Brad Bishop6e60e8b2018-02-01 10:27:11 -050060 if license:
61 if 'OR' in license:
62 license = license.replace('OR', '|')
63 license = license.replace('AND', '&')
64 license = license.replace(' ', '_')
65 if not license[0] == '(':
66 license = '(' + license + ')'
Brad Bishop6e60e8b2018-02-01 10:27:11 -050067 else:
68 license = license.replace('AND', '&')
69 if license[0] == '(':
70 license = license[1:]
71 if license[-1] == ')':
72 license = license[:-1]
73 license = license.replace('MIT/X11', 'MIT')
74 license = license.replace('Public Domain', 'PD')
75 license = license.replace('SEE LICENSE IN EULA',
76 'SEE-LICENSE-IN-EULA')
Patrick Williamsc0f7c042017-02-23 20:41:17 -060077 return license
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050078
Brad Bishop6e60e8b2018-02-01 10:27:11 -050079 def _shrinkwrap(self, srctree, localfilesdir, extravalues, lines_before, d):
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050080 try:
Brad Bishop6e60e8b2018-02-01 10:27:11 -050081 runenv = dict(os.environ, PATH=d.getVar('PATH'))
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050082 bb.process.run('npm shrinkwrap', cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
83 except bb.process.ExecutionError as e:
Brad Bishop1a4b7ee2018-12-16 17:11:34 -080084 logger.warning('npm shrinkwrap failed:\n%s' % e.stdout)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050085 return
86
87 tmpfile = os.path.join(localfilesdir, 'npm-shrinkwrap.json')
88 shutil.move(os.path.join(srctree, 'npm-shrinkwrap.json'), tmpfile)
89 extravalues.setdefault('extrafiles', {})
90 extravalues['extrafiles']['npm-shrinkwrap.json'] = tmpfile
91 lines_before.append('NPM_SHRINKWRAP := "${THISDIR}/${PN}/npm-shrinkwrap.json"')
92
Brad Bishop6e60e8b2018-02-01 10:27:11 -050093 def _lockdown(self, srctree, localfilesdir, extravalues, lines_before, d):
94 runenv = dict(os.environ, PATH=d.getVar('PATH'))
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050095 if not NpmRecipeHandler.lockdownpath:
96 NpmRecipeHandler.lockdownpath = tempfile.mkdtemp('recipetool-npm-lockdown')
97 bb.process.run('npm install lockdown --prefix %s' % NpmRecipeHandler.lockdownpath,
98 cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
99 relockbin = os.path.join(NpmRecipeHandler.lockdownpath, 'node_modules', 'lockdown', 'relock.js')
100 if not os.path.exists(relockbin):
Brad Bishop1a4b7ee2018-12-16 17:11:34 -0800101 logger.warning('Could not find relock.js within lockdown directory; skipping lockdown')
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500102 return
103 try:
104 bb.process.run('node %s' % relockbin, cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
105 except bb.process.ExecutionError as e:
Brad Bishop1a4b7ee2018-12-16 17:11:34 -0800106 logger.warning('lockdown-relock failed:\n%s' % e.stdout)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500107 return
108
109 tmpfile = os.path.join(localfilesdir, 'lockdown.json')
110 shutil.move(os.path.join(srctree, 'lockdown.json'), tmpfile)
111 extravalues.setdefault('extrafiles', {})
112 extravalues['extrafiles']['lockdown.json'] = tmpfile
113 lines_before.append('NPM_LOCKDOWN := "${THISDIR}/${PN}/lockdown.json"')
114
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500115 def _handle_dependencies(self, d, deps, optdeps, devdeps, lines_before, srctree):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600116 import scriptutils
117 # If this isn't a single module we need to get the dependencies
118 # and add them to SRC_URI
119 def varfunc(varname, origvalue, op, newlines):
120 if varname == 'SRC_URI':
121 if not origvalue.startswith('npm://'):
122 src_uri = origvalue.split()
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500123 deplist = {}
124 for dep, depver in optdeps.items():
125 depdata = self.get_npm_data(dep, depver, d)
126 if self.check_npm_optional_dependency(depdata):
127 deplist[dep] = depdata
128 for dep, depver in devdeps.items():
129 depdata = self.get_npm_data(dep, depver, d)
130 if self.check_npm_optional_dependency(depdata):
131 deplist[dep] = depdata
132 for dep, depver in deps.items():
133 depdata = self.get_npm_data(dep, depver, d)
134 deplist[dep] = depdata
135
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500136 extra_urls = []
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500137 for dep, depdata in deplist.items():
138 version = depdata.get('version', None)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600139 if version:
140 url = 'npm://registry.npmjs.org;name=%s;version=%s;subdir=node_modules/%s' % (dep, version, dep)
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500141 extra_urls.append(url)
142 if extra_urls:
143 scriptutils.fetch_url(tinfoil, ' '.join(extra_urls), None, srctree, logger)
144 src_uri.extend(extra_urls)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600145 return src_uri, None, -1, True
146 return origvalue, None, 0, True
147 updated, newlines = bb.utils.edit_metadata(lines_before, ['SRC_URI'], varfunc)
148 if updated:
149 del lines_before[:]
150 for line in newlines:
151 # Hack to avoid newlines that edit_metadata inserts
152 if line.endswith('\n'):
153 line = line[:-1]
154 lines_before.append(line)
155 return updated
156
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500157 def process(self, srctree, classes, lines_before, lines_after, handled, extravalues):
158 import bb.utils
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500159 import oe.package
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500160 from collections import OrderedDict
161
162 if 'buildsystem' in handled:
163 return False
164
165 def read_package_json(fn):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600166 with open(fn, 'r', errors='surrogateescape') as f:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500167 return json.loads(f.read())
168
169 files = RecipeHandler.checkfiles(srctree, ['package.json'])
170 if files:
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500171 d = bb.data.createCopy(tinfoil.config_data)
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500172 npm_bindir = self._ensure_npm()
173 if not npm_bindir:
174 sys.exit(14)
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500175 d.prependVar('PATH', '%s:' % npm_bindir)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600176
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500177 data = read_package_json(files[0])
178 if 'name' in data and 'version' in data:
179 extravalues['PN'] = data['name']
180 extravalues['PV'] = data['version']
181 classes.append('npm')
182 handled.append('buildsystem')
183 if 'description' in data:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600184 extravalues['SUMMARY'] = data['description']
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500185 if 'homepage' in data:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600186 extravalues['HOMEPAGE'] = data['homepage']
187
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500188 fetchdev = extravalues['fetchdev'] or None
189 deps, optdeps, devdeps = self.get_npm_package_dependencies(data, fetchdev)
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500190 self._handle_dependencies(d, deps, optdeps, devdeps, lines_before, srctree)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500191
192 # Shrinkwrap
193 localfilesdir = tempfile.mkdtemp(prefix='recipetool-npm')
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500194 self._shrinkwrap(srctree, localfilesdir, extravalues, lines_before, d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500195
196 # Lockdown
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500197 self._lockdown(srctree, localfilesdir, extravalues, lines_before, d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500198
199 # Split each npm module out to is own package
200 npmpackages = oe.package.npm_split_package_dirs(srctree)
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500201 licvalues = None
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500202 for item in handled:
203 if isinstance(item, tuple):
204 if item[0] == 'license':
205 licvalues = item[1]
206 break
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500207 if not licvalues:
208 licvalues = handle_license_vars(srctree, lines_before, handled, extravalues, d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500209 if licvalues:
210 # Augment the license list with information we have in the packages
211 licenses = {}
212 license = self._handle_license(data)
213 if license:
214 licenses['${PN}'] = license
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600215 for pkgname, pkgitem in npmpackages.items():
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500216 _, pdata = pkgitem
217 license = self._handle_license(pdata)
218 if license:
219 licenses[pkgname] = license
220 # Now write out the package-specific license values
221 # We need to strip out the json data dicts for this since split_pkg_licenses
222 # isn't expecting it
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600223 packages = OrderedDict((x,y[0]) for x,y in npmpackages.items())
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500224 packages['${PN}'] = ''
225 pkglicenses = split_pkg_licenses(licvalues, packages, lines_after, licenses)
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500226 all_licenses = list(set([item.replace('_', ' ') for pkglicense in pkglicenses.values() for item in pkglicense]))
227 if '&' in all_licenses:
228 all_licenses.remove('&')
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500229 extravalues['LICENSE'] = ' & '.join(all_licenses)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500230
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600231 # Need to move S setting after inherit npm
232 for i, line in enumerate(lines_before):
233 if line.startswith('S ='):
234 lines_before.pop(i)
235 lines_after.insert(0, '# Must be set after inherit npm since that itself sets S')
236 lines_after.insert(1, line)
237 break
238
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500239 return True
240
241 return False
242
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600243 # FIXME this is duplicated from lib/bb/fetch2/npm.py
244 def _parse_view(self, output):
245 '''
246 Parse the output of npm view --json; the last JSON result
247 is assumed to be the one that we're interested in.
248 '''
249 pdata = None
250 outdeps = {}
251 datalines = []
252 bracelevel = 0
253 for line in output.splitlines():
254 if bracelevel:
255 datalines.append(line)
256 elif '{' in line:
257 datalines = []
258 datalines.append(line)
259 bracelevel = bracelevel + line.count('{') - line.count('}')
260 if datalines:
261 pdata = json.loads('\n'.join(datalines))
262 return pdata
263
264 # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
265 # (split out from _getdependencies())
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500266 def get_npm_data(self, pkg, version, d):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600267 import bb.fetch2
268 pkgfullname = pkg
269 if version != '*' and not '/' in version:
270 pkgfullname += "@'%s'" % version
271 logger.debug(2, "Calling getdeps on %s" % pkg)
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500272 runenv = dict(os.environ, PATH=d.getVar('PATH'))
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600273 fetchcmd = "npm view %s --json" % pkgfullname
274 output, _ = bb.process.run(fetchcmd, stderr=subprocess.STDOUT, env=runenv, shell=True)
275 data = self._parse_view(output)
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500276 return data
277
278 # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
279 # (split out from _getdependencies())
280 def get_npm_package_dependencies(self, pdata, fetchdev):
281 dependencies = pdata.get('dependencies', {})
282 optionalDependencies = pdata.get('optionalDependencies', {})
283 dependencies.update(optionalDependencies)
284 if fetchdev:
285 devDependencies = pdata.get('devDependencies', {})
286 dependencies.update(devDependencies)
287 else:
288 devDependencies = {}
289 depsfound = {}
290 optdepsfound = {}
291 devdepsfound = {}
292 for dep in dependencies:
293 if dep in optionalDependencies:
294 optdepsfound[dep] = dependencies[dep]
295 elif dep in devDependencies:
296 devdepsfound[dep] = dependencies[dep]
297 else:
298 depsfound[dep] = dependencies[dep]
299 return depsfound, optdepsfound, devdepsfound
300
301 # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
302 # (split out from _getdependencies())
303 def check_npm_optional_dependency(self, pdata):
304 pkg_os = pdata.get('os', None)
305 if pkg_os:
306 if not isinstance(pkg_os, list):
307 pkg_os = [pkg_os]
308 blacklist = False
309 for item in pkg_os:
310 if item.startswith('!'):
311 blacklist = True
312 break
313 if (not blacklist and 'linux' not in pkg_os) or '!linux' in pkg_os:
Brad Bishop316dfdd2018-06-25 12:45:53 -0400314 pkg = pdata.get('name', 'Unnamed package')
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500315 logger.debug(2, "Skipping %s since it's incompatible with Linux" % pkg)
316 return False
317 return True
318
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600319
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500320def register_recipe_handlers(handlers):
321 handlers.append((NpmRecipeHandler(), 60))