@pnpm/resolving.git-resolver 1100.1.15 → 1100.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # @pnpm/git-resolver
2
2
 
3
+ ## 1100.1.17
4
+
5
+ ### Patch Changes
6
+
7
+ - A git dependency whose `git ls-remote` fails now reports the `ERR_PNPM_GIT_RESOLVE_FAILED` code, naming the dependency instead of printing a bare `git` invocation, with credentials in the repository URL redacted. A specifier that does not ask for SSH resolves over HTTPS, because the URL recorded in the lockfile has to work on every machine that installs it, so the error explains how to substitute the transport on a machine that can only reach the host over SSH (`git config --global url."git@<host>:".insteadOf "https://<host>/"`) [#13743](https://github.com/pnpm/pnpm/issues/13743).
8
+
9
+ A missing `git` executable is reported as one, instead of surfacing the raw failure to start the process.
10
+
11
+ Credentials embedded in a git specifier are redacted from the "Could not resolve \<ref\> to a commit of \<repo\>" errors too.
12
+
13
+ Resolving a public repository makes one `git ls-remote` round-trip instead of two.
14
+
15
+ - An `ssh://` git dependency pointing at a bracketed IPv6 host, such as `ssh://[::1]/repo.git`, is resolved now. Its colons were read as an SCP-style path separator, which turned the address into `[:/1]` and left the specifier unresolvable. Applies to both the TypeScript CLI and pacquet.
16
+
17
+ In the TypeScript CLI, an `ssh://` git dependency written without user info — `ssh://git.example.com/team/repo.git`, `git+ssh://git.example.com:2222/team/repo.git` — no longer fails with `TypeError: Cannot read properties of undefined (reading 'includes')`. Only the `user@host` form worked before.
18
+
19
+ - Updated dependencies:
20
+ - @pnpm/error@1100.1.2
21
+ - @pnpm/network.fetch@1100.1.12
22
+
23
+ ## 1100.1.16
24
+
25
+ ### Patch Changes
26
+
27
+ - Fixed a CI regression where `github:owner/repo` dependencies (and other shorthand Git specifiers) would fail to install with `Permission denied (publickey)` on CI runners that lack SSH keys. The Git resolver no longer records an SSH URL unless the user explicitly wrote one (e.g. `git+ssh://` or `git@host:...`):
28
+
29
+ - The repository visibility probe (an HTTP HEAD request) now retries transient failures such as `429 Too Many Requests`, so host throttling of CI runners is no longer mistaken for a private repository.
30
+ - For non-SSH specifiers, anonymous HTTPS `git ls-remote` access is now tried before SSH, so a public repository whose visibility probe fails still resolves to a portable HTTPS URL instead of an SSH URL that only works where SSH keys are configured.
31
+ - When every probe fails, the resolver falls back to HTTPS for shorthand and HTTPS-style specifiers, and only guesses SSH when the user explicitly provided an SSH URL.
32
+ - A repository that could not be confirmed public is no longer resolved to the host's anonymous archive URL (e.g. `codeload.github.com`, which would fail to download for a private repository); it stays a regular `git` resolution so installs can use ambient Git credentials such as credential helpers and tokens.
33
+
34
+ Note that a private repository that is reachable both over authenticated HTTPS and over SSH now resolves to its HTTPS URL, where previous versions recorded the SSH URL.
35
+
36
+ Fixes [pnpm/pnpm#13276](https://github.com/pnpm/pnpm/issues/13276).
37
+
38
+ <!-- cspell:ignore publickey -->
39
+
40
+ - Resolving a private git repository no longer blocks on an interactive credential prompt: `git ls-remote` now fails fast with an authentication error when git has no credentials for the repository [#13522](https://github.com/pnpm/pnpm/issues/13522).
41
+
3
42
  ## 1100.1.15
4
43
 
5
44
  ### Patch Changes
package/lib/index.js CHANGED
@@ -1,4 +1,6 @@
1
- import { PnpmError } from '@pnpm/error';
1
+ import assert from 'node:assert';
2
+ import util from 'node:util';
3
+ import { PnpmError, redactAndSanitize } from '@pnpm/error';
2
4
  import semver from 'semver';
3
5
  import { createGitHostedPkgId } from './createGitHostedPkgId.js';
4
6
  import { lsRemote } from './lsRemote.js';
@@ -33,7 +35,14 @@ export function createGitResolver(opts) {
33
35
  const bareSpecifier = parsedSpec.gitCommittish == null || parsedSpec.gitCommittish === ''
34
36
  ? 'HEAD'
35
37
  : parsedSpec.gitCommittish;
36
- const commit = await resolveRef(parsedSpec.fetchSpec, bareSpecifier, parsedSpec.gitRange);
38
+ let commit;
39
+ try {
40
+ commit = await resolveRef(parsedSpec.fetchSpec, bareSpecifier, parsedSpec.gitRange);
41
+ }
42
+ catch (err) {
43
+ assert(util.types.isNativeError(err));
44
+ throw gitResolveError(err, wantedDependency.bareSpecifier, parsedSpec.fetchSpec);
45
+ }
37
46
  let resolution;
38
47
  if ((parsedSpec.hosted != null) && !isSsh(parsedSpec.fetchSpec)) {
39
48
  // don't use tarball for ssh url, they are likely private repo
@@ -99,7 +108,6 @@ export async function getRepoRefs(repo, ref) {
99
108
  // This is needed because annotated tags have their own SHA, and we need the commit SHA they point to
100
109
  gitArgs.push(`${ref}^{}`);
101
110
  }
102
- // graceful-git by default retries 10 times, reduce to single retry
103
111
  const result = await lsRemote(gitArgs, { retries: 1 });
104
112
  const refs = {};
105
113
  for (const line of result.stdout.split('\n')) {
@@ -136,7 +144,7 @@ function resolveRefFromRefs(refs, repo, ref, committish, range) {
136
144
  commitId = commits[0];
137
145
  }
138
146
  else {
139
- throw new Error(`Could not resolve ${ref} to a commit of ${repo}.`);
147
+ throw new Error(`Could not resolve ${ref} to a commit of ${redactAndSanitize(repo)}.`);
140
148
  }
141
149
  }
142
150
  return commitId;
@@ -156,11 +164,50 @@ function resolveRefFromRefs(refs, repo, ref, committish, range) {
156
164
  (refs[`refs/tags/${refVTag}^{}`] || // prefer annotated tags
157
165
  refs[`refs/tags/${refVTag}`]);
158
166
  if (!commitId) {
159
- throw new Error(`Could not resolve ${range} to a commit of ${repo}. Available versions are: ${vTags.join(', ')}`);
167
+ throw new Error(`Could not resolve ${range} to a commit of ${redactAndSanitize(repo)}. Available versions are: ${vTags.join(', ')}`);
160
168
  }
161
169
  return commitId;
162
170
  }
163
171
  }
172
+ /**
173
+ * Restate a failed `git ls-remote` as `ERR_PNPM_GIT_RESOLVE_FAILED`, naming the
174
+ * dependency it was resolving. Errors that describe the refs the remote did
175
+ * return (an unknown ref, an ambiguous commit-ish) already say which repository
176
+ * they came from and are left alone.
177
+ */
178
+ function gitResolveError(err, bareSpecifier, repo) {
179
+ if (err.code !== 'ERR_PNPM_GIT_LS_REMOTE_FAILED')
180
+ return err;
181
+ return new PnpmError('GIT_RESOLVE_FAILED', `Failed to resolve git dependency "${redactAndSanitize(bareSpecifier)}": ${err.message}`, { hint: httpsTransportHint(repo) });
182
+ }
183
+ /**
184
+ * Guidance for a specifier that resolved over HTTPS on a machine whose git
185
+ * cannot use that transport, or `undefined` when the resolution already went
186
+ * over SSH — there, the transport that failed is the one the specifier asked
187
+ * for.
188
+ *
189
+ * Substituting the transport is git's job rather than pnpm's: the URL pnpm
190
+ * records has to work for every machine that installs the lockfile, while
191
+ * `insteadOf` rewrites it for this one only.
192
+ */
193
+ function httpsTransportHint(repo) {
194
+ let url;
195
+ try {
196
+ url = new URL(repo);
197
+ }
198
+ catch {
199
+ return undefined;
200
+ }
201
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
202
+ return undefined;
203
+ const host = redactAndSanitize(url.host);
204
+ const hostname = redactAndSanitize(url.hostname);
205
+ return `pnpm resolves this specifier over HTTPS because it does not ask for SSH, and the URL it records has to work on every machine that installs the lockfile.
206
+
207
+ If git can only reach ${hostname} over SSH here, substitute the transport locally, leaving the recorded URL alone:
208
+
209
+ git config --global url."git@${hostname}:".insteadOf "${url.protocol}//${host}/"`;
210
+ }
164
211
  function isSsh(gitSpec) {
165
212
  return gitSpec.slice(0, 10) === 'git+ssh://' ||
166
213
  gitSpec.slice(0, 4) === 'git@';
package/lib/lsRemote.d.ts CHANGED
@@ -2,6 +2,10 @@
2
2
  * Runs `git ls-remote` with interactive credential prompts disabled, so it
3
3
  * fails fast on private repos instead of blocking on user input. All
4
4
  * ls-remote invocations must go through this function to keep that guarantee.
5
+ *
6
+ * Failed runs are retried immediately, matching the Rust runner's policy.
7
+ * A run that fails every attempt throws `ERR_PNPM_GIT_LS_REMOTE_FAILED`,
8
+ * which the git resolver restates with the dependency it was resolving.
5
9
  */
6
10
  export declare function lsRemote(args: string[], opts: {
7
11
  retries: number;
package/lib/lsRemote.js CHANGED
@@ -1,15 +1,46 @@
1
- import { gracefulGit as git } from 'graceful-git';
1
+ import { PnpmError, redactAndSanitizeMultiline } from '@pnpm/error';
2
+ import { safeExeca as execa } from 'execa';
2
3
  /**
3
4
  * Runs `git ls-remote` with interactive credential prompts disabled, so it
4
5
  * fails fast on private repos instead of blocking on user input. All
5
6
  * ls-remote invocations must go through this function to keep that guarantee.
7
+ *
8
+ * Failed runs are retried immediately, matching the Rust runner's policy.
9
+ * A run that fails every attempt throws `ERR_PNPM_GIT_LS_REMOTE_FAILED`,
10
+ * which the git resolver restates with the dependency it was resolving.
6
11
  */
7
12
  export async function lsRemote(args, opts) {
8
- return git(['ls-remote', ...args], {
9
- retries: opts.retries,
10
- // Snapshotted per call so changes to auth/proxy env vars made by a
11
- // long-lived host process are picked up.
12
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
13
- });
13
+ let lastErr;
14
+ for (let attempt = 0; attempt <= opts.retries; attempt++) {
15
+ try {
16
+ const { stdout } = await execa('git', ['ls-remote', ...args], {
17
+ // Snapshotted per call so changes to auth/proxy env vars made by a
18
+ // long-lived host process are picked up.
19
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
20
+ });
21
+ return { stdout: stdout };
22
+ }
23
+ catch (err) {
24
+ lastErr = err;
25
+ }
26
+ }
27
+ throw lsRemoteError(lastErr);
28
+ }
29
+ /**
30
+ * git's stderr is untrusted: the repository URL it echoes back can carry
31
+ * `user:pass@` credentials, so it goes through
32
+ * {@link redactAndSanitizeMultiline} rather than being restated verbatim.
33
+ */
34
+ function lsRemoteError(err) {
35
+ return new PnpmError('GIT_LS_REMOTE_FAILED', `git ls-remote failed: ${redactAndSanitizeMultiline(lsRemoteFailureDetail(err))}`);
36
+ }
37
+ function lsRemoteFailureDetail(err) {
38
+ if (err.code === 'ENOENT') {
39
+ return '`git` executable not found on PATH. Install git to resolve git-hosted packages.';
40
+ }
41
+ const stderr = err.stderr?.trim();
42
+ if (stderr != null && stderr !== '')
43
+ return stderr;
44
+ return err.message ?? String(err);
14
45
  }
15
46
  //# sourceMappingURL=lsRemote.js.map
@@ -49,53 +49,53 @@ function urlToFetchSpec(url) {
49
49
  }
50
50
  async function fromHostedGit(hosted, dispatcherOptions) {
51
51
  let fetchSpec = null;
52
- // try git/https url before fallback to ssh url
53
- const gitHttpsUrl = hosted.https({ noCommittish: true, noGitPlus: true });
54
- if (gitHttpsUrl && await isRepoPublic(gitHttpsUrl, dispatcherOptions) && await accessRepository(gitHttpsUrl)) {
55
- fetchSpec = gitHttpsUrl;
52
+ const httpsUrl = hosted.https({ noCommittish: true, noGitPlus: true });
53
+ const sshUrl = hosted.ssh({ noCommittish: true });
54
+ // SSH is probed before the HTTPS fallbacks (and used as the last-resort guess)
55
+ // only when the user explicitly wrote an SSH URL. For every other representation
56
+ // (`shortcut`, `https`, ...) an SSH remote was never asked for, and recording one
57
+ // in the lockfile breaks installs in environments without SSH keys, so every
58
+ // HTTPS transport is exhausted first.
59
+ //
60
+ // Such a specifier therefore resolves over HTTPS whether or not git can reach
61
+ // the host that way on this machine, and its HTTPS access is not probed at all:
62
+ // the probe could not change what is recorded, and a machine that reaches the
63
+ // host only over SSH substitutes the transport itself through git's
64
+ // `url.<base>.insteadOf`. Only an explicit SSH URL, which HTTPS may displace,
65
+ // is worth the round-trip.
66
+ const preferSsh = hosted.default === 'sshurl';
67
+ const repoIsPublic = httpsUrl != null && await isRepoPublic(httpsUrl, dispatcherOptions);
68
+ if (httpsUrl && repoIsPublic && (!preferSsh || await accessRepository(httpsUrl))) {
69
+ fetchSpec = httpsUrl;
56
70
  }
57
- else {
58
- const gitSshUrl = hosted.ssh({ noCommittish: true });
59
- if (gitSshUrl && await accessRepository(gitSshUrl)) {
60
- fetchSpec = gitSshUrl;
61
- }
71
+ if (!fetchSpec && preferSsh && sshUrl && await accessRepository(sshUrl)) {
72
+ fetchSpec = sshUrl;
62
73
  }
63
- if (!fetchSpec) {
64
- const httpsUrl = hosted.https({ noGitPlus: true, noCommittish: true });
65
- if (httpsUrl) {
66
- if ((hosted.auth || !await isRepoPublic(httpsUrl, dispatcherOptions)) && await accessRepository(httpsUrl)) {
67
- return {
68
- fetchSpec: httpsUrl,
69
- hosted: {
70
- ...hosted,
71
- _fill: hosted._fill,
72
- tarball: undefined,
73
- },
74
- normalizedBareSpecifier: `git+${httpsUrl}`,
75
- ...parseGitParams(hosted.committish),
76
- };
77
- }
78
- else {
79
- try {
80
- // when git ls-remote private repo, it asks for login credentials.
81
- // use HTTP HEAD request to test whether this is a private repo, to avoid login prompt.
82
- // this is very similar to yarn classic's behavior.
83
- // npm instead tries git ls-remote directly which prompts user for login credentials.
84
- // HTTP HEAD on https://domain/user/repo, strip out ".git"
85
- const response = await fetchWithDispatcher(httpsUrl.replace(/\.git$/, ''), { method: 'HEAD', redirect: 'manual', retry: { retries: 0 }, dispatcherOptions });
86
- if (response.ok) {
87
- fetchSpec = httpsUrl;
88
- }
89
- }
90
- catch {
91
- // ignore
92
- }
93
- }
74
+ if (!fetchSpec && httpsUrl) {
75
+ if ((hosted.auth || !repoIsPublic) && await accessRepository(httpsUrl)) {
76
+ // Reachable over HTTPS without being provably public, so resolve as
77
+ // `type: git` against this exact URL: the host's archive endpoint would
78
+ // carry neither the URL's credentials nor ambient ones (helpers, tokens).
79
+ return {
80
+ fetchSpec: httpsUrl,
81
+ hosted: {
82
+ ...hosted,
83
+ _fill: hosted._fill,
84
+ tarball: undefined,
85
+ },
86
+ normalizedBareSpecifier: `git+${httpsUrl}`,
87
+ ...parseGitParams(hosted.committish),
88
+ };
89
+ }
90
+ if (repoIsPublic) {
91
+ fetchSpec = httpsUrl;
94
92
  }
95
93
  }
94
+ if (!fetchSpec && !preferSsh && sshUrl && await accessRepository(sshUrl)) {
95
+ fetchSpec = sshUrl;
96
+ }
96
97
  if (!fetchSpec) {
97
- // use ssh url for likely private repo
98
- fetchSpec = hosted.sshurl({ noCommittish: true });
98
+ fetchSpec = preferSsh ? hosted.sshurl({ noCommittish: true }) : httpsUrl;
99
99
  }
100
100
  return {
101
101
  fetchSpec: fetchSpec,
@@ -103,7 +103,10 @@ async function fromHostedGit(hosted, dispatcherOptions) {
103
103
  ...hosted,
104
104
  tarballtemplate: hosted.type === 'gitlab' ? gitlabTarballTemplate : hosted.tarballtemplate,
105
105
  _fill: hosted._fill,
106
- tarball: hosted.tarball,
106
+ // Same rationale as the early return above: without proof that the repo
107
+ // is public, the host's anonymous archive endpoint cannot be assumed to
108
+ // work, so the resolution must stay `type: git`.
109
+ tarball: repoIsPublic ? hosted.tarball : undefined,
107
110
  },
108
111
  normalizedBareSpecifier: hosted.shortcut(),
109
112
  ...parseGitParams(hosted.committish),
@@ -116,9 +119,20 @@ function gitlabTarballTemplate({ domain, user, project, committish }) {
116
119
  const ref = committish ? encodeURIComponent(committish) : 'HEAD';
117
120
  return `https://${domain}/${user}/${project}/-/archive/${ref}/${project}-${ref}.tar.gz`;
118
121
  }
122
+ // An HTTP HEAD on the project page (without ".git") instead of `git ls-remote`:
123
+ // probing a private repo with ls-remote would trigger a credential prompt. This is
124
+ // very similar to yarn classic's behavior; npm instead tries git ls-remote directly,
125
+ // which prompts for login credentials. Transient failures (429/5xx/network errors)
126
+ // are retried by the fetch layer so registry throttling of CI runners is not
127
+ // mistaken for a private repository.
119
128
  async function isRepoPublic(httpsUrl, dispatcherOptions) {
120
129
  try {
121
- const response = await fetchWithDispatcher(httpsUrl.replace(/\.git$/, ''), { method: 'HEAD', redirect: 'manual', retry: { retries: 0 }, dispatcherOptions });
130
+ const response = await fetchWithDispatcher(httpsUrl.replace(/\.git$/, ''), {
131
+ method: 'HEAD',
132
+ redirect: 'manual',
133
+ retry: { retries: 2, factor: 2, minTimeout: 500, maxTimeout: 2_000 },
134
+ dispatcherOptions,
135
+ });
122
136
  return response.ok;
123
137
  }
124
138
  catch {
@@ -165,8 +179,12 @@ function correctUrl(gitUrl) {
165
179
  _gitUrl = _gitUrl.slice(0, hashIndex);
166
180
  }
167
181
  const [auth, ...pathname] = _gitUrl.slice(6).split('/');
168
- const [, host] = auth.split('@');
169
- if (host.includes(':') && !/:\d+$/.test(host)) {
182
+ const userInfoEnd = auth.lastIndexOf('@');
183
+ const host = userInfoEnd === -1 ? auth : auth.slice(userInfoEnd + 1);
184
+ // The colons of a bracketed IPv6 literal belong to the address.
185
+ const bracketEnd = host.startsWith('[') ? host.indexOf(']') : -1;
186
+ const afterHost = bracketEnd === -1 ? host : host.slice(bracketEnd + 1);
187
+ if (afterHost.includes(':') && !/:\d+$/.test(afterHost)) {
170
188
  const authArr = auth.split(':');
171
189
  const protocol = gitUrl.split('://')[0];
172
190
  gitUrl = `${protocol}://${authArr.slice(0, -1).join(':') + '/' + authArr[authArr.length - 1]}${pathname.length ? '/' + pathname.join('/') : ''}${hash}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/resolving.git-resolver",
3
- "version": "1100.1.15",
3
+ "version": "1100.1.17",
4
4
  "description": "Resolver for git-hosted packages",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -29,19 +29,19 @@
29
29
  "!*.map"
30
30
  ],
31
31
  "dependencies": {
32
- "@pnpm/error": "1100.1.1",
33
- "@pnpm/network.fetch": "1100.1.11",
32
+ "@pnpm/error": "1100.1.2",
33
+ "@pnpm/network.fetch": "1100.1.12",
34
34
  "@pnpm/resolving.resolver-base": "1101.1.0",
35
- "graceful-git": "^5.0.0",
35
+ "execa": "npm:safe-execa@0.3.0",
36
36
  "hosted-git-info": "npm:@pnpm/hosted-git-info@1.0.0",
37
37
  "semver": "^7.8.5"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@jest/globals": "30.4.1",
41
- "@pnpm/resolving.git-resolver": "1100.1.15",
41
+ "@pnpm/resolving.git-resolver": "1100.1.17",
42
42
  "@types/hosted-git-info": "^3.0.5",
43
43
  "@types/is-windows": "^1.0.2",
44
- "@types/semver": "7.7.1",
44
+ "@types/semver": "7.8.0",
45
45
  "is-windows": "^1.0.2"
46
46
  },
47
47
  "engines": {