blob: bb42a5ca5c0742161cc247857fa8b46eaca92b45 [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#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License version 2 as
7# published by the Free Software Foundation.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License along
15# with this program; if not, write to the Free Software Foundation, Inc.,
16# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
17
18import os
19import logging
20import subprocess
21import tempfile
22import shutil
23import json
Brad Bishopd7bf8c12018-02-25 22:55:05 -050024from recipetool.create import RecipeHandler, split_pkg_licenses, handle_license_vars
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050025
26logger = logging.getLogger('recipetool')
27
28
29tinfoil = None
30
31def tinfoil_init(instance):
32 global tinfoil
33 tinfoil = instance
34
35
36class NpmRecipeHandler(RecipeHandler):
37 lockdownpath = None
38
Brad Bishopd7bf8c12018-02-25 22:55:05 -050039 def _ensure_npm(self, fixed_setup=False):
40 if not tinfoil.recipes_parsed:
41 tinfoil.parse_recipes()
42 try:
43 rd = tinfoil.parse_recipe('nodejs-native')
44 except bb.providers.NoProvider:
45 if fixed_setup:
46 msg = 'nodejs-native is required for npm but is not available within this SDK'
47 else:
48 msg = 'nodejs-native is required for npm but is not available - you will likely need to add a layer that provides nodejs'
49 logger.error(msg)
50 return None
51 bindir = rd.getVar('STAGING_BINDIR_NATIVE')
52 npmpath = os.path.join(bindir, 'npm')
53 if not os.path.exists(npmpath):
54 tinfoil.build_targets('nodejs-native', 'addto_recipe_sysroot')
55 if not os.path.exists(npmpath):
56 logger.error('npm required to process specified source, but nodejs-native did not seem to populate it')
57 return None
58 return bindir
59
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050060 def _handle_license(self, data):
61 '''
62 Handle the license value from an npm package.json file
63 '''
64 license = None
65 if 'license' in data:
66 license = data['license']
67 if isinstance(license, dict):
68 license = license.get('type', None)
Brad Bishop6e60e8b2018-02-01 10:27:11 -050069 if license:
70 if 'OR' in license:
71 license = license.replace('OR', '|')
72 license = license.replace('AND', '&')
73 license = license.replace(' ', '_')
74 if not license[0] == '(':
75 license = '(' + license + ')'
Brad Bishop6e60e8b2018-02-01 10:27:11 -050076 else:
77 license = license.replace('AND', '&')
78 if license[0] == '(':
79 license = license[1:]
80 if license[-1] == ')':
81 license = license[:-1]
82 license = license.replace('MIT/X11', 'MIT')
83 license = license.replace('Public Domain', 'PD')
84 license = license.replace('SEE LICENSE IN EULA',
85 'SEE-LICENSE-IN-EULA')
Patrick Williamsc0f7c042017-02-23 20:41:17 -060086 return license
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050087
Brad Bishop6e60e8b2018-02-01 10:27:11 -050088 def _shrinkwrap(self, srctree, localfilesdir, extravalues, lines_before, d):
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050089 try:
Brad Bishop6e60e8b2018-02-01 10:27:11 -050090 runenv = dict(os.environ, PATH=d.getVar('PATH'))
Patrick Williamsd8c66bc2016-06-20 12:57:21 -050091 bb.process.run('npm shrinkwrap', cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
92 except bb.process.ExecutionError as e:
93 logger.warn('npm shrinkwrap failed:\n%s' % e.stdout)
94 return
95
96 tmpfile = os.path.join(localfilesdir, 'npm-shrinkwrap.json')
97 shutil.move(os.path.join(srctree, 'npm-shrinkwrap.json'), tmpfile)
98 extravalues.setdefault('extrafiles', {})
99 extravalues['extrafiles']['npm-shrinkwrap.json'] = tmpfile
100 lines_before.append('NPM_SHRINKWRAP := "${THISDIR}/${PN}/npm-shrinkwrap.json"')
101
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500102 def _lockdown(self, srctree, localfilesdir, extravalues, lines_before, d):
103 runenv = dict(os.environ, PATH=d.getVar('PATH'))
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500104 if not NpmRecipeHandler.lockdownpath:
105 NpmRecipeHandler.lockdownpath = tempfile.mkdtemp('recipetool-npm-lockdown')
106 bb.process.run('npm install lockdown --prefix %s' % NpmRecipeHandler.lockdownpath,
107 cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
108 relockbin = os.path.join(NpmRecipeHandler.lockdownpath, 'node_modules', 'lockdown', 'relock.js')
109 if not os.path.exists(relockbin):
110 logger.warn('Could not find relock.js within lockdown directory; skipping lockdown')
111 return
112 try:
113 bb.process.run('node %s' % relockbin, cwd=srctree, stderr=subprocess.STDOUT, env=runenv, shell=True)
114 except bb.process.ExecutionError as e:
115 logger.warn('lockdown-relock failed:\n%s' % e.stdout)
116 return
117
118 tmpfile = os.path.join(localfilesdir, 'lockdown.json')
119 shutil.move(os.path.join(srctree, 'lockdown.json'), tmpfile)
120 extravalues.setdefault('extrafiles', {})
121 extravalues['extrafiles']['lockdown.json'] = tmpfile
122 lines_before.append('NPM_LOCKDOWN := "${THISDIR}/${PN}/lockdown.json"')
123
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500124 def _handle_dependencies(self, d, deps, optdeps, devdeps, lines_before, srctree):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600125 import scriptutils
126 # If this isn't a single module we need to get the dependencies
127 # and add them to SRC_URI
128 def varfunc(varname, origvalue, op, newlines):
129 if varname == 'SRC_URI':
130 if not origvalue.startswith('npm://'):
131 src_uri = origvalue.split()
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500132 deplist = {}
133 for dep, depver in optdeps.items():
134 depdata = self.get_npm_data(dep, depver, d)
135 if self.check_npm_optional_dependency(depdata):
136 deplist[dep] = depdata
137 for dep, depver in devdeps.items():
138 depdata = self.get_npm_data(dep, depver, d)
139 if self.check_npm_optional_dependency(depdata):
140 deplist[dep] = depdata
141 for dep, depver in deps.items():
142 depdata = self.get_npm_data(dep, depver, d)
143 deplist[dep] = depdata
144
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500145 extra_urls = []
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500146 for dep, depdata in deplist.items():
147 version = depdata.get('version', None)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600148 if version:
149 url = 'npm://registry.npmjs.org;name=%s;version=%s;subdir=node_modules/%s' % (dep, version, dep)
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500150 extra_urls.append(url)
151 if extra_urls:
152 scriptutils.fetch_url(tinfoil, ' '.join(extra_urls), None, srctree, logger)
153 src_uri.extend(extra_urls)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600154 return src_uri, None, -1, True
155 return origvalue, None, 0, True
156 updated, newlines = bb.utils.edit_metadata(lines_before, ['SRC_URI'], varfunc)
157 if updated:
158 del lines_before[:]
159 for line in newlines:
160 # Hack to avoid newlines that edit_metadata inserts
161 if line.endswith('\n'):
162 line = line[:-1]
163 lines_before.append(line)
164 return updated
165
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500166 def process(self, srctree, classes, lines_before, lines_after, handled, extravalues):
167 import bb.utils
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500168 import oe.package
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500169 from collections import OrderedDict
170
171 if 'buildsystem' in handled:
172 return False
173
174 def read_package_json(fn):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600175 with open(fn, 'r', errors='surrogateescape') as f:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500176 return json.loads(f.read())
177
178 files = RecipeHandler.checkfiles(srctree, ['package.json'])
179 if files:
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500180 d = bb.data.createCopy(tinfoil.config_data)
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500181 npm_bindir = self._ensure_npm()
182 if not npm_bindir:
183 sys.exit(14)
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500184 d.prependVar('PATH', '%s:' % npm_bindir)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600185
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500186 data = read_package_json(files[0])
187 if 'name' in data and 'version' in data:
188 extravalues['PN'] = data['name']
189 extravalues['PV'] = data['version']
190 classes.append('npm')
191 handled.append('buildsystem')
192 if 'description' in data:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600193 extravalues['SUMMARY'] = data['description']
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500194 if 'homepage' in data:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600195 extravalues['HOMEPAGE'] = data['homepage']
196
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500197 fetchdev = extravalues['fetchdev'] or None
198 deps, optdeps, devdeps = self.get_npm_package_dependencies(data, fetchdev)
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500199 self._handle_dependencies(d, deps, optdeps, devdeps, lines_before, srctree)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500200
201 # Shrinkwrap
202 localfilesdir = tempfile.mkdtemp(prefix='recipetool-npm')
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500203 self._shrinkwrap(srctree, localfilesdir, extravalues, lines_before, d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500204
205 # Lockdown
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500206 self._lockdown(srctree, localfilesdir, extravalues, lines_before, d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500207
208 # Split each npm module out to is own package
209 npmpackages = oe.package.npm_split_package_dirs(srctree)
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500210 licvalues = None
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500211 for item in handled:
212 if isinstance(item, tuple):
213 if item[0] == 'license':
214 licvalues = item[1]
215 break
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500216 if not licvalues:
217 licvalues = handle_license_vars(srctree, lines_before, handled, extravalues, d)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500218 if licvalues:
219 # Augment the license list with information we have in the packages
220 licenses = {}
221 license = self._handle_license(data)
222 if license:
223 licenses['${PN}'] = license
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600224 for pkgname, pkgitem in npmpackages.items():
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500225 _, pdata = pkgitem
226 license = self._handle_license(pdata)
227 if license:
228 licenses[pkgname] = license
229 # Now write out the package-specific license values
230 # We need to strip out the json data dicts for this since split_pkg_licenses
231 # isn't expecting it
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600232 packages = OrderedDict((x,y[0]) for x,y in npmpackages.items())
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500233 packages['${PN}'] = ''
234 pkglicenses = split_pkg_licenses(licvalues, packages, lines_after, licenses)
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500235 all_licenses = list(set([item.replace('_', ' ') for pkglicense in pkglicenses.values() for item in pkglicense]))
236 if '&' in all_licenses:
237 all_licenses.remove('&')
Brad Bishopd7bf8c12018-02-25 22:55:05 -0500238 extravalues['LICENSE'] = ' & '.join(all_licenses)
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500239
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600240 # Need to move S setting after inherit npm
241 for i, line in enumerate(lines_before):
242 if line.startswith('S ='):
243 lines_before.pop(i)
244 lines_after.insert(0, '# Must be set after inherit npm since that itself sets S')
245 lines_after.insert(1, line)
246 break
247
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500248 return True
249
250 return False
251
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600252 # FIXME this is duplicated from lib/bb/fetch2/npm.py
253 def _parse_view(self, output):
254 '''
255 Parse the output of npm view --json; the last JSON result
256 is assumed to be the one that we're interested in.
257 '''
258 pdata = None
259 outdeps = {}
260 datalines = []
261 bracelevel = 0
262 for line in output.splitlines():
263 if bracelevel:
264 datalines.append(line)
265 elif '{' in line:
266 datalines = []
267 datalines.append(line)
268 bracelevel = bracelevel + line.count('{') - line.count('}')
269 if datalines:
270 pdata = json.loads('\n'.join(datalines))
271 return pdata
272
273 # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
274 # (split out from _getdependencies())
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500275 def get_npm_data(self, pkg, version, d):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600276 import bb.fetch2
277 pkgfullname = pkg
278 if version != '*' and not '/' in version:
279 pkgfullname += "@'%s'" % version
280 logger.debug(2, "Calling getdeps on %s" % pkg)
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500281 runenv = dict(os.environ, PATH=d.getVar('PATH'))
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600282 fetchcmd = "npm view %s --json" % pkgfullname
283 output, _ = bb.process.run(fetchcmd, stderr=subprocess.STDOUT, env=runenv, shell=True)
284 data = self._parse_view(output)
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500285 return data
286
287 # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
288 # (split out from _getdependencies())
289 def get_npm_package_dependencies(self, pdata, fetchdev):
290 dependencies = pdata.get('dependencies', {})
291 optionalDependencies = pdata.get('optionalDependencies', {})
292 dependencies.update(optionalDependencies)
293 if fetchdev:
294 devDependencies = pdata.get('devDependencies', {})
295 dependencies.update(devDependencies)
296 else:
297 devDependencies = {}
298 depsfound = {}
299 optdepsfound = {}
300 devdepsfound = {}
301 for dep in dependencies:
302 if dep in optionalDependencies:
303 optdepsfound[dep] = dependencies[dep]
304 elif dep in devDependencies:
305 devdepsfound[dep] = dependencies[dep]
306 else:
307 depsfound[dep] = dependencies[dep]
308 return depsfound, optdepsfound, devdepsfound
309
310 # FIXME this is effectively duplicated from lib/bb/fetch2/npm.py
311 # (split out from _getdependencies())
312 def check_npm_optional_dependency(self, pdata):
313 pkg_os = pdata.get('os', None)
314 if pkg_os:
315 if not isinstance(pkg_os, list):
316 pkg_os = [pkg_os]
317 blacklist = False
318 for item in pkg_os:
319 if item.startswith('!'):
320 blacklist = True
321 break
322 if (not blacklist and 'linux' not in pkg_os) or '!linux' in pkg_os:
Brad Bishop316dfdd2018-06-25 12:45:53 -0400323 pkg = pdata.get('name', 'Unnamed package')
Brad Bishop6e60e8b2018-02-01 10:27:11 -0500324 logger.debug(2, "Skipping %s since it's incompatible with Linux" % pkg)
325 return False
326 return True
327
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600328
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500329def register_recipe_handlers(handlers):
330 handlers.append((NpmRecipeHandler(), 60))