lol
1#!/usr/bin/env node
2'use strict'
3
4const fs = require('fs')
5const crypto = require('crypto')
6const process = require('process')
7const https = require('https')
8const child_process = require('child_process')
9const path = require('path')
10const lockfile = require('./yarnpkg-lockfile.js')
11const { promisify } = require('util')
12const url = require('url')
13const { urlToName } = require('./common.js')
14
15const execFile = promisify(child_process.execFile)
16
17const exec = async (...args) => {
18 const res = await execFile(...args)
19 if (res.error) throw new Error(res.stderr)
20 return res
21}
22
23const downloadFileHttps = (fileName, url, expectedHash, hashType = 'sha1') => {
24 return new Promise((resolve, reject) => {
25 const get = (url, redirects = 0) => https.get(url, (res) => {
26 if(redirects > 10) {
27 reject('Too many redirects!');
28 return;
29 }
30 if(res.statusCode === 301 || res.statusCode === 302) {
31 return get(res.headers.location, redirects + 1)
32 }
33 const file = fs.createWriteStream(fileName)
34 const hash = crypto.createHash(hashType)
35 res.pipe(file)
36 res.pipe(hash).setEncoding('hex')
37 res.on('end', () => {
38 file.close()
39 const h = hash.read()
40 if (expectedHash === undefined){
41 console.log(`Warning: lockfile url ${url} doesn't end in "#<hash>" to validate against. Downloaded file had hash ${h}.`);
42 } else if (h != expectedHash) return reject(new Error(`hash mismatch, expected ${expectedHash}, got ${h}`))
43 resolve()
44 })
45 res.on('error', e => reject(e))
46 })
47 get(url)
48 })
49}
50
51const downloadGit = async (fileName, url, rev) => {
52 await exec('nix-prefetch-git', [
53 '--out', fileName + '.tmp',
54 '--url', url,
55 '--rev', rev,
56 '--builder'
57 ])
58
59 await exec('tar', [
60 // hopefully make it reproducible across runs and systems
61 '--owner=0', '--group=0', '--numeric-owner', '--format=gnu', '--sort=name', '--mtime=@1',
62
63 // Set u+w because tar-fs can't unpack archives with read-only dirs: https://github.com/mafintosh/tar-fs/issues/79
64 '--mode', 'u+w',
65
66 '-C', fileName + '.tmp',
67 '-cf', fileName, '.'
68 ])
69
70 await exec('rm', [ '-rf', fileName + '.tmp', ])
71}
72
73const isGitUrl = pattern => {
74 // https://github.com/yarnpkg/yarn/blob/3119382885ea373d3c13d6a846de743eca8c914b/src/resolvers/exotics/git-resolver.js#L15-L47
75 const GIT_HOSTS = ['github.com', 'gitlab.com', 'bitbucket.com', 'bitbucket.org']
76 const GIT_PATTERN_MATCHERS = [/^git:/, /^git\+.+:/, /^ssh:/, /^https?:.+\.git$/, /^https?:.+\.git#.+/]
77
78 for (const matcher of GIT_PATTERN_MATCHERS) if (matcher.test(pattern)) return true
79
80 const {hostname, path} = url.parse(pattern)
81 if (hostname && path && GIT_HOSTS.indexOf(hostname) >= 0
82 // only if dependency is pointing to a git repo,
83 // e.g. facebook/flow and not file in a git repo facebook/flow/archive/v1.0.0.tar.gz
84 && path.split('/').filter(p => !!p).length === 2
85 ) return true
86
87 return false
88}
89
90const downloadPkg = (pkg, verbose) => {
91 const [ name, spec ] = pkg.key.split('@', 2);
92 if (spec.startsWith('file:')) {
93 console.info(`ignoring relative file:path dependency "${spec}"`)
94 return
95 }
96
97 const [ url, hash ] = pkg.resolved.split('#')
98 if (verbose) console.log('downloading ' + url)
99 const fileName = urlToName(url)
100 if (url.startsWith('https://codeload.github.com/') && url.includes('/tar.gz/')) {
101 const s = url.split('/')
102 return downloadGit(fileName, `https://github.com/${s[3]}/${s[4]}.git`, s[s.length-1])
103 } else if (url.startsWith('https://github.com/') && url.endsWith('.tar.gz')) {
104 const s = url.split('/')
105 return downloadGit(fileName, `https://github.com/${s[3]}/${s[4]}.git`, s[s.length-1].replace(/.tar.gz$/, ''))
106 } else if (isGitUrl(url)) {
107 return downloadGit(fileName, url.replace(/^git\+/, ''), hash)
108 } else if (url.startsWith('https://')) {
109 if (typeof pkg.integrity === 'string' || pkg.integrity instanceof String) {
110 const [ type, checksum ] = pkg.integrity.split('-')
111 return downloadFileHttps(fileName, url, Buffer.from(checksum, 'base64').toString('hex'), type)
112 }
113 return downloadFileHttps(fileName, url, hash)
114 } else if (url.startsWith('file:')) {
115 console.warn(`ignoring unsupported file:path url "${url}"`)
116 } else {
117 throw new Error('don\'t know how to download "' + url + '"')
118 }
119}
120
121const performParallel = tasks => {
122 const worker = async () => {
123 while (tasks.length > 0) await tasks.shift()()
124 }
125
126 const workers = []
127 for (let i = 0; i < 4; i++) {
128 workers.push(worker())
129 }
130
131 return Promise.all(workers)
132}
133
134const prefetchYarnDeps = async (lockContents, verbose) => {
135 const lockData = lockfile.parse(lockContents)
136 const tasks = Object.values(
137 Object.entries(lockData.object)
138 .map(([key, value]) => {
139 return { key, ...value }
140 })
141 .reduce((out, pkg) => {
142 out[pkg.resolved] = pkg
143 return out
144 }, {})
145 )
146 .map(pkg => () => downloadPkg(pkg, verbose))
147
148 await performParallel(tasks)
149 await fs.promises.writeFile('yarn.lock', lockContents)
150 if (verbose) console.log('Done')
151}
152
153const showUsage = async () => {
154 process.stderr.write(`
155syntax: prefetch-yarn-deps [path to yarn.lock] [options]
156
157Options:
158 -h --help Show this help
159 -v --verbose Verbose output
160 --builder Only perform the download to current directory, then exit
161`)
162 process.exit(1)
163}
164
165const main = async () => {
166 const args = process.argv.slice(2)
167 let next, lockFile, verbose, isBuilder
168 while (next = args.shift()) {
169 if (next == '--builder') {
170 isBuilder = true
171 } else if (next == '--verbose' || next == '-v') {
172 verbose = true
173 } else if (next == '--help' || next == '-h') {
174 showUsage()
175 } else if (!lockFile) {
176 lockFile = next
177 } else {
178 showUsage()
179 }
180 }
181 let lockContents
182 try {
183 lockContents = await fs.promises.readFile(lockFile || 'yarn.lock', 'utf-8')
184 } catch {
185 showUsage()
186 }
187
188 if (isBuilder) {
189 await prefetchYarnDeps(lockContents, verbose)
190 } else {
191 const { stdout: tmpDir } = await exec('mktemp', [ '-d' ])
192
193 try {
194 process.chdir(tmpDir.trim())
195 await prefetchYarnDeps(lockContents, verbose)
196 const { stdout: hash } = await exec('nix-hash', [ '--type', 'sha256', '--base32', tmpDir.trim() ])
197 console.log(hash)
198 } finally {
199 await exec('rm', [ '-rf', tmpDir.trim() ])
200 }
201 }
202}
203
204main()
205 .catch(e => {
206 console.error(e)
207 process.exit(1)
208 })