git.js 11 KB

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