codekingpro/portable-devtools
115k
1'use strict'
2
3const { createWriteStream, promises: fs } = require('graceful-fs')
4const os = require('os')
5const { backOff } = require('exponential-backoff')
6const tar = require('tar')
7const path = require('path')
8const { Transform, promises: { pipeline } } = require('stream')
9const crypto = require('crypto')
10const log = require('./log')
11const semver = require('semver')
12const { download } = require('./download')
13const processRelease = require('./process-release')
14
15const win = process.platform === 'win32'
16
17async function install (gyp, argv) {
18 log.stdout()
19 const release = processRelease(argv, gyp, process.version, process.release)
20 // Detecting target_arch based on logic from create-cnfig-gyp.js. Used on Windows only.
21 const arch = win ? (gyp.opts.target_arch || gyp.opts.arch || process.arch || 'ia32') : ''
22 // Used to prevent downloading tarball if only new node.lib is required on Windows.
23 let shouldDownloadTarball = true
24
25 // Determine which node dev files version we are installing
26 log.verbose('install', 'input version string %j', release.version)
27
28 if (!release.semver) {
29 // could not parse the version string with semver
30 throw new Error('Invalid version number: ' + release.version)
31 }
32
33 if (semver.lt(release.version, '0.8.0')) {
34 throw new Error('Minimum target version is `0.8.0` or greater. Got: ' + release.version)
35 }
36
37 // 0.x.y-pre versions are not published yet and cannot be installed. Bail.
38 if (release.semver.prerelease[0] === 'pre') {
39 log.verbose('detected "pre" node version', release.version)
40 if (!gyp.opts.nodedir) {
41 throw new Error('"pre" versions of node cannot be installed, use the --nodedir flag instead')
42 }
43 log.verbose('--nodedir flag was passed; skipping install', gyp.opts.nodedir)
44 return
45 }
46
47 // flatten version into String
48 log.verbose('install', 'installing version: %s', release.versionDir)
49
50 // the directory where the dev files will be installed
51 const devDir = path.resolve(gyp.devDir, release.versionDir)
52
53 // If '--ensure' was passed, then don't *always* install the version;
54 // check if it is already installed, and only install when needed
55 if (gyp.opts.ensure) {
56 log.verbose('install', '--ensure was passed, so won\'t reinstall if already installed')
57 try {
58 await fs.stat(devDir)
59 } catch (err) {
60 if (err.code === 'ENOENT') {
61 log.verbose('install', 'version not already installed, continuing with install', release.version)
62 try {
63 return await go()
64 } catch (err) {
65 return rollback(err)
66 }
67 } else if (err.code === 'EACCES') {
68 return eaccesFallback(err)
69 }
70 throw err
71 }
72 log.verbose('install', 'version is already installed, need to check "installVersion"')
73 const installVersionFile = path.resolve(devDir, 'installVersion')
74 let installVersion = 0
75 try {
76 const ver = await fs.readFile(installVersionFile, 'ascii')
77 installVersion = parseInt(ver, 10) || 0
78 } catch (err) {
79 if (err.code !== 'ENOENT') {
80 throw err
81 }
82 }
83 log.verbose('got "installVersion"', installVersion)
84 log.verbose('needs "installVersion"', gyp.package.installVersion)
85 if (installVersion < gyp.package.installVersion) {
86 log.verbose('install', 'version is no good; reinstalling')
87 try {
88 return await go()
89 } catch (err) {
90 return rollback(err)
91 }
92 }
93 log.verbose('install', 'version is good')
94 if (win) {
95 log.verbose('on Windows; need to check node.lib')
96 const nodeLibPath = path.resolve(devDir, arch, 'node.lib')
97 try {
98 await fs.stat(nodeLibPath)
99 } catch (err) {
100 if (err.code === 'ENOENT') {
101 log.verbose('install', `version not already installed for ${arch}, continuing with install`, release.version)
102 try {
103 shouldDownloadTarball = false
104 return await go()
105 } catch (err) {
106 return rollback(err)
107 }
108 } else if (err.code === 'EACCES') {
109 return eaccesFallback(err)
110 }
111 throw err
112 }
113 }
114 } else {
115 try {
116 return await go()
117 } catch (err) {
118 return rollback(err)
119 }
120 }
121
122 async function copyDirectory (src, dest) {
123 try {
124 await fs.stat(src)
125 } catch {
126 throw new Error(`Missing source directory for copy: ${src}`)
127 }
128 await fs.mkdir(dest, { recursive: true })
129 const entries = await fs.readdir(src, { withFileTypes: true })
130 for (const entry of entries) {
131 if (entry.isDirectory()) {
132 await copyDirectory(path.join(src, entry.name), path.join(dest, entry.name))
133 } else if (entry.isFile()) {
134 // with parallel installs, copying files may cause file errors on
135 // Windows so use an exponential backoff to resolve collisions
136 await backOff(async () => {
137 try {
138 await fs.copyFile(path.join(src, entry.name), path.join(dest, entry.name))
139 } catch (err) {
140 // if ensure, check if file already exists and that's good enough
141 if (gyp.opts.ensure && err.code === 'EBUSY') {
142 try {
143 await fs.stat(path.join(dest, entry.name))
144 return
145 } catch {}
146 }
147 throw err
148 }
149 })
150 } else {
151 throw new Error('Unexpected file directory entry type')
152 }
153 }
154 }
155
156 async function go () {
157 log.verbose('ensuring devDir is created', devDir)
158
159 // first create the dir for the node dev files
160 try {
161 const created = await fs.mkdir(devDir, { recursive: true })
162
163 if (created) {
164 log.verbose('created devDir', created)
165 }
166 } catch (err) {
167 if (err.code === 'EACCES') {
168 return eaccesFallback(err)
169 }
170
171 throw err
172 }
173
174 // now download the node tarball
175 const tarPath = gyp.opts.tarball
176 let extractErrors = false
177 let extractCount = 0
178 const contentShasums = {}
179 const expectShasums = {}
180
181 // checks if a file to be extracted from the tarball is valid.
182 // only .h header files and the gyp files get extracted
183 function isValid (path) {
184 const isValid = valid(path)
185 if (isValid) {
186 log.verbose('extracted file from tarball', path)
187 extractCount++
188 } else {
189 // invalid
190 log.silly('ignoring from tarball', path)
191 }
192 return isValid
193 }
194
195 function onwarn (code, message) {
196 extractErrors = true
197 log.error('error while extracting tarball', code, message)
198 }
199
200 // download the tarball and extract!
201 // Omitted on Windows if only new node.lib is required
202
203 // there can be file errors from tar if parallel installs
204 // are happening (not uncommon with multiple native modules) so
205 // extract the tarball to a temp directory first and then copy over
206 const tarExtractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'node-gyp-tmp-'))
207
208 try {
209 if (shouldDownloadTarball) {
210 if (tarPath) {
211 await tar.extract({
212 file: tarPath,
213 strip: 1,
214 filter: isValid,
215 onwarn,
216 cwd: tarExtractDir
217 })
218 } else {
219 try {
220 const res = await download(gyp, release.tarballUrl)
221
222 if (res.status !== 200) {
223 throw new Error(`${res.status} response downloading ${release.tarballUrl}`)
224 }
225
226 await pipeline(
227 res.body,
228 // content checksum
229 new ShaSum((_, checksum) => {
230 const filename = path.basename(release.tarballUrl).trim()
231 contentShasums[filename] = checksum
232 log.verbose('content checksum', filename, checksum)
233 }),
234 tar.extract({
235 strip: 1,
236 cwd: tarExtractDir,
237 filter: isValid,
238 onwarn
239 })
240 )
241 } catch (err) {
242 // something went wrong downloading the tarball?
243 if (err.code === 'ENOTFOUND') {
244 throw new Error('This is most likely not a problem with node-gyp or the package itself and\n' +
245 'is related to network connectivity. In most cases you are behind a proxy or have bad \n' +
246 'network settings.')
247 }
248 throw err
249 }
250 }
251
252 // invoked after the tarball has finished being extracted
253 if (extractErrors || extractCount === 0) {
254 throw new Error('There was a fatal problem while downloading/extracting the tarball')
255 }
256
257 log.verbose('tarball', 'done parsing tarball')
258 }
259
260 const installVersionPath = path.resolve(tarExtractDir, 'installVersion')
261 await Promise.all([
262 // need to download node.lib
263 ...(win ? [downloadNodeLib()] : []),
264 // write the "installVersion" file
265 fs.writeFile(installVersionPath, gyp.package.installVersion + '\n'),
266 // Only download SHASUMS.txt if we downloaded something in need of SHA verification
267 ...(!tarPath || win ? [downloadShasums()] : [])
268 ])
269
270 log.verbose('download contents checksum', JSON.stringify(contentShasums))
271 // check content shasums
272 for (const k in contentShasums) {
273 log.verbose('validating download checksum for ' + k, '(%s == %s)', contentShasums[k], expectShasums[k])
274 if (contentShasums[k] !== expectShasums[k]) {
275 throw new Error(k + ' local checksum ' + contentShasums[k] + ' not match remote ' + expectShasums[k])
276 }
277 }
278
279 // copy over the files from the temp tarball extract directory to devDir
280 await copyDirectory(tarExtractDir, devDir)
281 } finally {
282 try {
283 // try to cleanup temp dir
284 await fs.rm(tarExtractDir, { recursive: true, maxRetries: 3 })
285 } catch {
286 log.warn('failed to clean up temp tarball extract directory')
287 }
288 }
289
290 async function downloadShasums () {
291 log.verbose('check download content checksum, need to download `SHASUMS256.txt`...')
292 log.verbose('checksum url', release.shasumsUrl)
293
294 const res = await download(gyp, release.shasumsUrl)
295
296 if (res.status !== 200) {
297 throw new Error(`${res.status} status code downloading checksum`)
298 }
299
300 for (const line of (await res.text()).trim().split('\n')) {
301 const items = line.trim().split(/\s+/)
302 if (items.length !== 2) {
303 return
304 }
305
306 // 0035d18e2dcf9aad669b1c7c07319e17abfe3762 ./node-v0.11.4.tar.gz
307 const name = items[1].replace(/^\.\//, '')
308 expectShasums[name] = items[0]
309 }
310
311 log.verbose('checksum data', JSON.stringify(expectShasums))
312 }
313
314 async function downloadNodeLib () {
315 log.verbose('on Windows; need to download `' + release.name + '.lib`...')
316 const dir = path.resolve(tarExtractDir, arch)
317 const targetLibPath = path.resolve(dir, release.name + '.lib')
318 const { libUrl, libPath } = release[arch]
319 const name = `${arch} ${release.name}.lib`
320 log.verbose(name, 'dir', dir)
321 log.verbose(name, 'url', libUrl)
322
323 await fs.mkdir(dir, { recursive: true })
324 log.verbose('streaming', name, 'to:', targetLibPath)
325
326 const res = await download(gyp, libUrl)
327
328 // Since only required node.lib is downloaded throw error if it is not fetched
329 if (res.status !== 200) {
330 throw new Error(`${res.status} status code downloading ${name}`)
331 }
332
333 return pipeline(
334 res.body,
335 new ShaSum((_, checksum) => {
336 contentShasums[libPath] = checksum
337 log.verbose('content checksum', libPath, checksum)
338 }),
339 createWriteStream(targetLibPath)
340 )
341 } // downloadNodeLib()
342 } // go()
343
344 /**
345 * Checks if a given filename is "valid" for this installation.
346 */
347
348 function valid (file) {
349 // header files
350 const extname = path.extname(file)
351 return extname === '.h' || extname === '.gypi'
352 }
353
354 async function rollback (err) {
355 log.warn('install', 'got an error, rolling back install')
356 // roll-back the install if anything went wrong
357 await gyp.commands.remove([release.versionDir])
358 throw err
359 }
360
361 /**
362 * The EACCES fallback is a workaround for npm's `sudo` behavior, where
363 * it drops the permissions before invoking any child processes (like
364 * node-gyp). So what happens is the "nobody" user doesn't have
365 * permission to create the dev dir. As a fallback, make the tmpdir() be
366 * the dev dir for this installation. This is not ideal, but at least
367 * the compilation will succeed...
368 */
369
370 async function eaccesFallback (err) {
371 const noretry = '--node_gyp_internal_noretry'
372 if (argv.indexOf(noretry) !== -1) {
373 throw err
374 }
375 const tmpdir = os.tmpdir()
376 gyp.devDir = path.resolve(tmpdir, '.node-gyp')
377 let userString = ''
378 try {
379 // os.userInfo can fail on some systems, it's not critical here
380 userString = ` ("${os.userInfo().username}")`
381 } catch (e) {}
382 log.warn('EACCES', 'current user%s does not have permission to access the dev dir "%s"', userString, devDir)
383 log.warn('EACCES', 'attempting to reinstall using temporary dev dir "%s"', gyp.devDir)
384 if (process.cwd() === tmpdir) {
385 log.verbose('tmpdir == cwd', 'automatically will remove dev files after to save disk space')
386 gyp.todo.push({ name: 'remove', args: argv })
387 }
388 return gyp.commands.install([noretry].concat(argv))
389 }
390}
391
392class ShaSum extends Transform {
393 constructor (callback) {
394 super()
395 this._callback = callback
396 this._digester = crypto.createHash('sha256')
397 }
398
399 _transform (chunk, _, callback) {
400 this._digester.update(chunk)
401 callback(null, chunk)
402 }
403
404 _flush (callback) {
405 this._callback(null, this._digester.digest('hex'))
406 callback()
407 }
408}
409
410module.exports = install
411module.exports.usage = 'Install node development files for the specified node version.'
412 