1const Fetcher = require('./fetcher.js') 2const FileFetcher = require('./file.js') 3const RemoteFetcher = require('./remote.js') 4const DirFetcher = require('./dir.js') 5const hashre = /^[a-f0-9]{40}$/ 6const git = require('@npmcli/git') 7const pickManifest = require('npm-pick-manifest') 8const npa = require('npm-package-arg') 9const { Minipass } = require('minipass') 10const cacache = require('cacache') 11const log = require('proc-log') 12const npm = require('./util/npm.js') 13 14const _resolvedFromRepo = Symbol('_resolvedFromRepo') 15const _resolvedFromHosted = Symbol('_resolvedFromHosted') 16const _resolvedFromClone = Symbol('_resolvedFromClone') 17const _tarballFromResolved = Symbol.for('pacote.Fetcher._tarballFromResolved') 18const _addGitSha = Symbol('_addGitSha') 19const addGitSha = require('./util/add-git-sha.js') 20const _clone = Symbol('_clone') 21const _cloneHosted = Symbol('_cloneHosted') 22const _cloneRepo = Symbol('_cloneRepo') 23const _setResolvedWithSha = Symbol('_setResolvedWithSha') 24const _prepareDir = Symbol('_prepareDir') 25const _readPackageJson = Symbol.for('package.Fetcher._readPackageJson') 26 27// get the repository url. 28// prefer https if there's auth, since ssh will drop that. 29// otherwise, prefer ssh if available (more secure). 30// We have to add the git+ back because npa suppresses it. 31const repoUrl = (h, opts) => 32 h.sshurl && !(h.https && h.auth) && addGitPlus(h.sshurl(opts)) || 33 h.https && addGitPlus(h.https(opts)) 34 35// add git+ to the url, but only one time. 36const addGitPlus = url => url && `git+${url}`.replace(/^(git\+)+/, 'git+') 37 38class GitFetcher extends Fetcher { 39 constructor (spec, opts) { 40 super(spec, opts) 41 42 // we never want to compare integrity for git dependencies: npm/rfcs#525 43 if (this.opts.integrity) { 44 delete this.opts.integrity 45 log.warn(`skipping integrity check for git dependency ${this.spec.fetchSpec}`) 46 } 47 48 this.resolvedRef = null 49 if (this.spec.hosted) { 50 this.from = this.spec.hosted.shortcut({ noCommittish: false }) 51 } 52 53 // shortcut: avoid full clone when we can go straight to the tgz 54 // if we have the full sha and it's a hosted git platform 55 if (this.spec.gitCommittish && hashre.test(this.spec.gitCommittish)) { 56 this.resolvedSha = this.spec.gitCommittish 57 // use hosted.tarball() when we shell to RemoteFetcher later 58 this.resolved = this.spec.hosted 59 ? repoUrl(this.spec.hosted, { noCommittish: false }) 60 : this.spec.rawSpec 61 } else { 62 this.resolvedSha = '' 63 } 64 65 this.Arborist = opts.Arborist || null 66 } 67 68 // just exposed to make it easier to test all the combinations 69 static repoUrl (hosted, opts) { 70 return repoUrl(hosted, opts) 71 } 72 73 get types () { 74 return ['git'] 75 } 76 77 resolve () { 78 // likely a hosted git repo with a sha, so get the tarball url 79 // but in general, no reason to resolve() more than necessary! 80 if (this.resolved) { 81 return super.resolve() 82 } 83 84 // fetch the git repo and then look at the current hash 85 const h = this.spec.hosted 86 // try to use ssh, fall back to git. 87 return h ? this[_resolvedFromHosted](h) 88 : this[_resolvedFromRepo](this.spec.fetchSpec) 89 } 90 91 // first try https, since that's faster and passphrase-less for 92 // public repos, and supports private repos when auth is provided. 93 // Fall back to SSH to support private repos 94 // NB: we always store the https url in resolved field if auth 95 // is present, otherwise ssh if the hosted type provides it 96 [_resolvedFromHosted] (hosted) { 97 return this[_resolvedFromRepo](hosted.https && hosted.https()) 98 .catch(er => { 99 // Throw early since we know pathspec errors will fail again if retried 100 if (er instanceof git.errors.GitPathspecError) { 101 throw er 102 } 103 const ssh = hosted.sshurl && hosted.sshurl() 104 // no fallthrough if we can't fall through or have https auth 105 if (!ssh || hosted.auth) { 106 throw er 107 } 108 return this[_resolvedFromRepo](ssh) 109 }) 110 } 111 112 [_resolvedFromRepo] (gitRemote) { 113 // XXX make this a custom error class 114 if (!gitRemote) { 115 return Promise.reject(new Error(`No git url for ${this.spec}`)) 116 } 117 const gitRange = this.spec.gitRange 118 const name = this.spec.name 119 return git.revs(gitRemote, this.opts).then(remoteRefs => { 120 return gitRange ? pickManifest({ 121 versions: remoteRefs.versions, 122 'dist-tags': remoteRefs['dist-tags'], 123 name, 124 }, gitRange, this.opts) 125 : this.spec.gitCommittish ? 126 remoteRefs.refs[this.spec.gitCommittish] || 127 remoteRefs.refs[remoteRefs.shas[this.spec.gitCommittish]] 128 : remoteRefs.refs.HEAD // no git committish, get default head 129 }).then(revDoc => { 130 // the committish provided isn't in the rev list 131 // things like HEAD~3 or @yesterday can land here. 132 if (!revDoc || !revDoc.sha) { 133 return this[_resolvedFromClone]() 134 } 135 136 this.resolvedRef = revDoc 137 this.resolvedSha = revDoc.sha 138 this[_addGitSha](revDoc.sha) 139 return this.resolved 140 }) 141 } 142 143 [_setResolvedWithSha] (withSha) { 144 // we haven't cloned, so a tgz download is still faster 145 // of course, if it's not a known host, we can't do that. 146 this.resolved = !this.spec.hosted ? withSha 147 : repoUrl(npa(withSha).hosted, { noCommittish: false }) 148 } 149 150 // when we get the git sha, we affix it to our spec to build up 151 // either a git url with a hash, or a tarball download URL 152 [_addGitSha] (sha) { 153 this[_setResolvedWithSha](addGitSha(this.spec, sha)) 154 } 155 156 [_resolvedFromClone] () { 157 // do a full or shallow clone, then look at the HEAD 158 // kind of wasteful, but no other option, really 159 return this[_clone](dir => this.resolved) 160 } 161 162 [_prepareDir] (dir) { 163 return this[_readPackageJson](dir + '/package.json').then(mani => { 164 // no need if we aren't going to do any preparation. 165 const scripts = mani.scripts 166 if (!mani.workspaces && (!scripts || !( 167 scripts.postinstall || 168 scripts.build || 169 scripts.preinstall || 170 scripts.install || 171 scripts.prepack || 172 scripts.prepare))) { 173 return 174 } 175 176 // to avoid cases where we have an cycle of git deps that depend 177 // on one another, we only ever do preparation for one instance 178 // of a given git dep along the chain of installations. 179 // Note that this does mean that a dependency MAY in theory end up 180 // trying to run its prepare script using a dependency that has not 181 // been properly prepared itself, but that edge case is smaller 182 // and less hazardous than a fork bomb of npm and git commands. 183 const noPrepare = !process.env._PACOTE_NO_PREPARE_ ? [] 184 : process.env._PACOTE_NO_PREPARE_.split('\n') 185 if (noPrepare.includes(this.resolved)) { 186 log.info('prepare', 'skip prepare, already seen', this.resolved) 187 return 188 } 189 noPrepare.push(this.resolved) 190 191 // the DirFetcher will do its own preparation to run the prepare scripts 192 // All we have to do is put the deps in place so that it can succeed. 193 return npm( 194 this.npmBin, 195 [].concat(this.npmInstallCmd).concat(this.npmCliConfig), 196 dir, 197 { ...process.env, _PACOTE_NO_PREPARE_: noPrepare.join('\n') }, 198 { message: 'git dep preparation failed' } 199 ) 200 }) 201 } 202 203 [_tarballFromResolved] () { 204 const stream = new Minipass() 205 stream.resolved = this.resolved 206 stream.from = this.from 207 208 // check it out and then shell out to the DirFetcher tarball packer 209 this[_clone](dir => this[_prepareDir](dir) 210 .then(() => new Promise((res, rej) => { 211 if (!this.Arborist) { 212 throw new Error('GitFetcher requires an Arborist constructor to pack a tarball') 213 } 214 const df = new DirFetcher(`file:${dir}`, { 215 ...this.opts, 216 Arborist: this.Arborist, 217 resolved: null, 218 integrity: null, 219 }) 220 const dirStream = df[_tarballFromResolved]() 221 dirStream.on('error', rej) 222 dirStream.on('end', res) 223 dirStream.pipe(stream) 224 }))).catch( 225 /* istanbul ignore next: very unlikely and hard to test */ 226 er => stream.emit('error', er) 227 ) 228 return stream 229 } 230 231 // clone a git repo into a temp folder (or fetch and unpack if possible) 232 // handler accepts a directory, and returns a promise that resolves 233 // when we're done with it, at which point, cacache deletes it 234 // 235 // TODO: after cloning, create a tarball of the folder, and add to the cache 236 // with cacache.put.stream(), using a key that's deterministic based on the 237 // spec and repo, so that we don't ever clone the same thing multiple times. 238 [_clone] (handler, tarballOk = true) { 239 const o = { tmpPrefix: 'git-clone' } 240 const ref = this.resolvedSha || this.spec.gitCommittish 241 const h = this.spec.hosted 242 const resolved = this.resolved 243 244 // can be set manually to false to fall back to actual git clone 245 tarballOk = tarballOk && 246 h && resolved === repoUrl(h, { noCommittish: false }) && h.tarball 247 248 return cacache.tmp.withTmp(this.cache, o, async tmp => { 249 // if we're resolved, and have a tarball url, shell out to RemoteFetcher 250 if (tarballOk) { 251 const nameat = this.spec.name ? `${this.spec.name}@` : '' 252 return new RemoteFetcher(h.tarball({ noCommittish: false }), { 253 ...this.opts, 254 allowGitIgnore: true, 255 pkgid: `git:${nameat}${this.resolved}`, 256 resolved: this.resolved, 257 integrity: null, // it'll always be different, if we have one 258 }).extract(tmp).then(() => handler(tmp), er => { 259 // fall back to ssh download if tarball fails 260 if (er.constructor.name.match(/^Http/)) { 261 return this[_clone](handler, false) 262 } else { 263 throw er 264 } 265 }) 266 } 267 268 const sha = await ( 269 h ? this[_cloneHosted](ref, tmp) 270 : this[_cloneRepo](this.spec.fetchSpec, ref, tmp) 271 ) 272 this.resolvedSha = sha 273 if (!this.resolved) { 274 await this[_addGitSha](sha) 275 } 276 return handler(tmp) 277 }) 278 } 279 280 // first try https, since that's faster and passphrase-less for 281 // public repos, and supports private repos when auth is provided. 282 // Fall back to SSH to support private repos 283 // NB: we always store the https url in resolved field if auth 284 // is present, otherwise ssh if the hosted type provides it 285 [_cloneHosted] (ref, tmp) { 286 const hosted = this.spec.hosted 287 return this[_cloneRepo](hosted.https({ noCommittish: true }), ref, tmp) 288 .catch(er => { 289 // Throw early since we know pathspec errors will fail again if retried 290 if (er instanceof git.errors.GitPathspecError) { 291 throw er 292 } 293 const ssh = hosted.sshurl && hosted.sshurl({ noCommittish: true }) 294 // no fallthrough if we can't fall through or have https auth 295 if (!ssh || hosted.auth) { 296 throw er 297 } 298 return this[_cloneRepo](ssh, ref, tmp) 299 }) 300 } 301 302 [_cloneRepo] (repo, ref, tmp) { 303 const { opts, spec } = this 304 return git.clone(repo, ref, tmp, { ...opts, spec }) 305 } 306 307 manifest () { 308 if (this.package) { 309 return Promise.resolve(this.package) 310 } 311 312 return this.spec.hosted && this.resolved 313 ? FileFetcher.prototype.manifest.apply(this) 314 : this[_clone](dir => 315 this[_readPackageJson](dir + '/package.json') 316 .then(mani => this.package = { 317 ...mani, 318 _resolved: this.resolved, 319 _from: this.from, 320 })) 321 } 322 323 packument () { 324 return FileFetcher.prototype.packument.apply(this) 325 } 326} 327module.exports = GitFetcher 328