dshmarket 1.61.0 → 1.62.0

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/client/client.js CHANGED
@@ -436,6 +436,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
436
436
  sortDownloads: "npm 下载量(近 30 天)",
437
437
  /** Discover/theme card byline tooltip for catalog `version`. */
438
438
  catalogNpmLatest: "npm 当前 latest",
439
+ catalogVersionDated: "目录数据({0} 刷新)里记录的 npm 版本;在那之后发布的版本要等下一次目录刷新才会出现",
440
+ catalogVersionUndated: "目录数据里记录的 npm 版本;目录每日刷新,所以可能滞后于 npm 上的实际 latest",
439
441
  sortStars: "Star 数",
440
442
  sortAdded: "发布时间",
441
443
  sortDesc: "降序",
@@ -1024,6 +1026,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1024
1026
  sortDownloads: "npm downloads (30d)",
1025
1027
  /** Discover/theme card byline tooltip for catalog `version`. */
1026
1028
  catalogNpmLatest: "npm latest",
1029
+ catalogVersionDated: "the npm version as of the catalog's last refresh ({0}); a release published since then appears after the next refresh",
1030
+ catalogVersionUndated: "the npm version as of the catalog's last refresh — the catalog updates daily, so this can lag npm's actual latest",
1027
1031
  sortStars: "Stars",
1028
1032
  sortAdded: "Release date",
1029
1033
  sortDesc: "Descending",
@@ -7175,6 +7179,15 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7175
7179
  * first reply always has to ask which one it was.
7176
7180
  */
7177
7181
  const [version, setVersion] = (0, react.useState)(null);
7182
+ /**
7183
+ * The catalog's own build date (#712). The version on a card is the
7184
+ * catalog's copy, not a live npm lookup, so a plugin published after the
7185
+ * last refresh shows the older number — the tooltip has to say so, or
7186
+ * "npm latest" beside a number that is not npm's latest is a bug report
7187
+ * waiting to be filed. Null until the catalog answers.
7188
+ */
7189
+ const [catalogUpdated, setCatalogUpdated] = (0, react.useState)(null);
7190
+ const catalogVersionTip = (0, react.useMemo)(() => catalogUpdated === null ? t("catalogVersionUndated") : t("catalogVersionDated").replace("{0}", catalogUpdated), [catalogUpdated, t]);
7178
7191
  /** Non-live activation results from the last operation, shown as a banner. */
7179
7192
  const [activationWarnings, setActivationWarnings] = (0, react.useState)([]);
7180
7193
  const [hostDependencyFindings, setHostDependencyFindings] = (0, react.useState)([]);
@@ -7363,6 +7376,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7363
7376
  }
7364
7377
  cachedRegistry = body.registry;
7365
7378
  setData(body.registry);
7379
+ const catalogDate = body.registry.updated;
7380
+ setCatalogUpdated(typeof catalogDate === "string" && catalogDate.trim() !== "" ? catalogDate : null);
7366
7381
  setHostVersion(typeof body.hostVersion === "string" ? body.hostVersion : null);
7367
7382
  setLoadError(null);
7368
7383
  }).catch((error) => {
@@ -9119,7 +9134,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9119
9134
  }),
9120
9135
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CatalogVersionMark, {
9121
9136
  version: p.version,
9122
- tip: t("catalogNpmLatest")
9137
+ tip: catalogVersionTip
9123
9138
  }),
9124
9139
  typeof p.downloads === "number" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
9125
9140
  label: String(p.downloads),
@@ -9296,7 +9311,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
9296
9311
  }),
9297
9312
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CatalogVersionMark, {
9298
9313
  version: p.version,
9299
- tip: t("catalogNpmLatest")
9314
+ tip: catalogVersionTip
9300
9315
  }),
9301
9316
  typeof p.downloads === "number" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
9302
9317
  label: String(p.downloads),
@@ -11446,7 +11461,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
11446
11461
  }),
11447
11462
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CatalogVersionMark, {
11448
11463
  version: confirming.version,
11449
- tip: t("catalogNpmLatest")
11464
+ tip: catalogVersionTip
11450
11465
  }),
11451
11466
  typeof confirming.downloads === "number" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
11452
11467
  label: String(confirming.downloads),
package/lib/dsh-cli.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * Installs run through node:child_process, not ctx.shell: the shell service is
7
7
  * the agent's sandboxed executor and denies writes to the profile directory.
8
8
  */
9
- import { spawn } from 'node:child_process';
9
+ import { spawn, spawnSync } from 'node:child_process';
10
10
  import { existsSync } from 'node:fs';
11
11
  import { homedir } from 'node:os';
12
12
  import { dirname, isAbsolute, join, resolve } from 'node:path';
@@ -215,20 +215,78 @@ export function toolSearchDirs(platform = process.platform, env = process.env, h
215
215
  * precisely the branch that cannot work from a spawned child.
216
216
  *
217
217
  * Scope, stated plainly because it is narrower than the issue title
218
- * suggests: this is the HTTPS half. pnpm falls back to `git@github.com:`
219
- * when HTTPS fails, and the ssh side needs `GIT_SSH_COMMAND`, which
220
- * overrides `core.sshCommand` and `GIT_SSH` — the two ordinary ways to
221
- * choose an identity — and whose `BatchMode=yes` would disable
222
- * `SSH_ASKPASS`, breaking key-passphrase installs that work today. That
223
- * half needs a policy decision, so it is not made here. Note also that on
224
- * POSIX `runDshPlugin` spawns detached, so the subtree has no controlling
225
- * terminal and the prompt already dies instantly; the hang the issue
226
- * reports needs Windows, where the spawn is not detached.
218
+ * suggests: this is the HTTPS half, and the FIRST attempt HTTPS is. Measured
219
+ * with a `GIT_SSH_COMMAND` sentinel that always fails: pnpm's clone of a
220
+ * `github:owner/repo#path:/sub` spec succeeds without ever calling it, so
221
+ * ssh is the fallback that runs after HTTPS fails — which is the private
222
+ * repository case, not the common one.
223
+ *
224
+ * The ssh half (#596, @JINITAIMI121) is a policy decision, and it is made
225
+ * here: `BatchMode=yes`, so a passphrase or host-key question fails fast
226
+ * instead of waiting on a terminal nobody is watching. The cost is real and
227
+ * is why it is conditional — `BatchMode` also disables `SSH_ASKPASS`, so a
228
+ * key that NEEDS a passphrase stops prompting even where something could
229
+ * have answered, and the install fails where it used to work.
230
+ *
231
+ * So it is set only when the user has expressed NO ssh preference at all:
232
+ * `GIT_SSH_COMMAND` and `GIT_SSH` in the environment, and `core.sshCommand`
233
+ * in git config — the three ordinary ways to choose an identity, and
234
+ * `GIT_SSH_COMMAND` would silently override all three (measured: with
235
+ * `core.sshCommand` set, ours wins and theirs is never run). A user who
236
+ * has an agent or a specific key configured keeps exactly what they have;
237
+ * the prompt is closed for everyone else, and `classifyPnpmFailure` says
238
+ * what to do if that turns out to be the passphrase case.
227
239
  */
228
- export function gitEnvForPnpm(env = process.env) {
229
- if ((env.GIT_TERMINAL_PROMPT ?? '').trim() !== '')
230
- return {};
231
- return { GIT_TERMINAL_PROMPT: '0' };
240
+ let coreSshCommandProbe;
241
+ /**
242
+ * `core.sshCommand` from the user's git configuration, or null.
243
+ *
244
+ * The third place an ssh identity hides, and the one the environment cannot
245
+ * show: `git config --get` answers it. Memoized for the process — a user
246
+ * does not reconfigure git mid-install, and this runs on every spawn.
247
+ *
248
+ * @returns the configured command, or null when git has none (including
249
+ * when git is absent — an unreadable answer is not a choice).
250
+ */
251
+ export function probeCoreSshCommand(env) {
252
+ // Memoized only for the caller that has no opinion: a test that supplies
253
+ // its own environment is asking a different question and must get the
254
+ // fresh answer.
255
+ if (env === undefined && coreSshCommandProbe !== undefined)
256
+ return coreSshCommandProbe ?? null;
257
+ try {
258
+ const out = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
259
+ encoding: 'utf8',
260
+ timeout: 5_000,
261
+ stdio: ['ignore', 'pipe', 'ignore'],
262
+ ...(env === undefined ? {} : { env }),
263
+ // A user's git may be a shim that prints to a console; nothing here
264
+ // wants a window.
265
+ windowsHide: true,
266
+ });
267
+ const answer = out.status === 0 && typeof out.stdout === 'string' && out.stdout.trim() !== ''
268
+ ? out.stdout.trim()
269
+ : null;
270
+ if (env === undefined)
271
+ coreSshCommandProbe = answer;
272
+ return answer;
273
+ }
274
+ catch {
275
+ if (env === undefined)
276
+ coreSshCommandProbe = null;
277
+ return null;
278
+ }
279
+ }
280
+ export function gitEnvForPnpm(env = process.env, coreSshCommand = probeCoreSshCommand()) {
281
+ const out = {};
282
+ // Blank counts as unset: an empty value is not a setting git can parse.
283
+ const speaks = (value) => (value ?? '').trim() !== '';
284
+ if (!speaks(env.GIT_TERMINAL_PROMPT))
285
+ out.GIT_TERMINAL_PROMPT = '0';
286
+ const choseElsewhere = speaks(env.GIT_SSH_COMMAND) || speaks(env.GIT_SSH) || speaks(coreSshCommand ?? '');
287
+ if (!choseElsewhere)
288
+ out.GIT_SSH_COMMAND = 'ssh -oBatchMode=yes';
289
+ return out;
232
290
  }
233
291
  /**
234
292
  * Every `--config.<key>=<value>` override in the argv, repeated as the
@@ -344,6 +344,21 @@ export function classifyPnpmFailure(output, exitCode) {
344
344
  };
345
345
  }
346
346
  }
347
+ // #596: the ssh half of #587. git asks for a passphrase (or a host-key
348
+ // confirmation) on a terminal a spawned child does not have; the market
349
+ // closes that prompt with `BatchMode=yes` so the question becomes a fast
350
+ // failure instead of a fifteen-minute hang. This is that failure, and it
351
+ // needs its own message because git's own words send the reader to the
352
+ // wrong place: `Permission denied (publickey)` reads as "your key is
353
+ // wrong", and the key is usually fine — it wants a passphrase, and the
354
+ // channel that would have asked for it is exactly what was shut.
355
+ if (/Permission denied \(publickey\)|Could not read from remote repository/.test(output)) {
356
+ return {
357
+ code: 'ssh-auth-failed',
358
+ recoverable: false,
359
+ message: 'git 无法在无人值守的情况下完成 SSH 认证。如果你的 SSH key 设了密码,请用 ssh-agent(ssh-add),或自己设置 GIT_SSH_COMMAND 指向你的命令,市场不会覆盖它。 / git could not complete SSH authentication unattended. If your SSH key has a passphrase, use ssh-agent (ssh-add), or set GIT_SSH_COMMAND to your own command — the market leaves yours alone.',
360
+ };
361
+ }
347
362
  if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
348
363
  return {
349
364
  code: 'git-prepare-not-allowed',
@@ -79,39 +79,17 @@ export declare function proxyEnvForPnpm(env?: NodeJS.ProcessEnv, region?: Region
79
79
  */
80
80
  export declare function toolSearchDirs(platform?: string, env?: NodeJS.ProcessEnv, home?: string): string[];
81
81
  /**
82
- * Stop git asking for credentials down a channel nobody is listening on
83
- * (#587).
82
+ * `core.sshCommand` from the user's git configuration, or null.
84
83
  *
85
- * `CI=true` below is the same defence one layer up, and pnpm reads it. git
86
- * does not — it has its own switch, and it was not set. The gap only opens
87
- * when a spec reaches pnpm's git fetcher instead of the codeload tarball
88
- * path `accelerate.ts` describes: `github:owner/repo#path:/sub` is one, and
89
- * pnpm really does shell out to `git` for it, trying HTTPS first.
84
+ * The third place an ssh identity hides, and the one the environment cannot
85
+ * show: `git config --get` answers it. Memoized for the process — a user
86
+ * does not reconfigure git mid-install, and this runs on every spawn.
90
87
  *
91
- * git's credential prompt opens the controlling terminal, not stdin. There
92
- * is no terminal here, so the question is never seen and never answered:
93
- * the reporter caught `git.exe` alive for eight minutes having burned 0.05s
94
- * of CPU, and only the fifteen-minute install timeout ended it. Refusing
95
- * the prompt turns that into a fast, readable failure.
96
- *
97
- * It is a default, not an override: a value the caller set wins, and blank
98
- * counts as unset because an empty `GIT_TERMINAL_PROMPT` is not a setting
99
- * git can parse either. Credential helpers and `GIT_ASKPASS` are untouched
100
- * and still answer first — this closes only the terminal fallback, which is
101
- * precisely the branch that cannot work from a spawned child.
102
- *
103
- * Scope, stated plainly because it is narrower than the issue title
104
- * suggests: this is the HTTPS half. pnpm falls back to `git@github.com:`
105
- * when HTTPS fails, and the ssh side needs `GIT_SSH_COMMAND`, which
106
- * overrides `core.sshCommand` and `GIT_SSH` — the two ordinary ways to
107
- * choose an identity — and whose `BatchMode=yes` would disable
108
- * `SSH_ASKPASS`, breaking key-passphrase installs that work today. That
109
- * half needs a policy decision, so it is not made here. Note also that on
110
- * POSIX `runDshPlugin` spawns detached, so the subtree has no controlling
111
- * terminal and the prompt already dies instantly; the hang the issue
112
- * reports needs Windows, where the spawn is not detached.
88
+ * @returns the configured command, or null when git has none (including
89
+ * when git is absent — an unreadable answer is not a choice).
113
90
  */
114
- export declare function gitEnvForPnpm(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
91
+ export declare function probeCoreSshCommand(env?: NodeJS.ProcessEnv): string | null;
92
+ export declare function gitEnvForPnpm(env?: NodeJS.ProcessEnv, coreSshCommand?: string | null): NodeJS.ProcessEnv;
115
93
  /**
116
94
  * Every `--config.<key>=<value>` override in the argv, repeated as the
117
95
  * `PNPM_CONFIG_<KEY>` environment variable that pnpm 12 still reads.
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
33
33
  */
34
34
  export declare const HOST_NAMESPACE_RE: RegExp;
35
35
  export interface PnpmFailure {
36
- code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom';
36
+ code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed';
37
37
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
38
38
  message: string;
39
39
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.61.0",
4
+ "version": "1.62.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -1527,6 +1527,20 @@ export function MarketSection(props: MarketSectionProps) {
1527
1527
  * first reply always has to ask which one it was.
1528
1528
  */
1529
1529
  const [version, setVersion] = useState<string | null>(null)
1530
+ /**
1531
+ * The catalog's own build date (#712). The version on a card is the
1532
+ * catalog's copy, not a live npm lookup, so a plugin published after the
1533
+ * last refresh shows the older number — the tooltip has to say so, or
1534
+ * "npm latest" beside a number that is not npm's latest is a bug report
1535
+ * waiting to be filed. Null until the catalog answers.
1536
+ */
1537
+ const [catalogUpdated, setCatalogUpdated] = useState<string | null>(null)
1538
+ const catalogVersionTip = useMemo(
1539
+ () => (catalogUpdated === null
1540
+ ? t('catalogVersionUndated')
1541
+ : t('catalogVersionDated').replace('{0}', catalogUpdated)),
1542
+ [catalogUpdated, t],
1543
+ )
1530
1544
  /** Non-live activation results from the last operation, shown as a banner. */
1531
1545
  const [activationWarnings, setActivationWarnings] = useState<{ name: string; info: ActivationInfo }[]>([])
1532
1546
  const [hostDependencyFindings, setHostDependencyFindings] = useState<SharedHostPackageDependencyFinding[]>([])
@@ -1739,6 +1753,8 @@ export function MarketSection(props: MarketSectionProps) {
1739
1753
  }
1740
1754
  cachedRegistry = body.registry
1741
1755
  setData(body.registry)
1756
+ const catalogDate = body.registry.updated
1757
+ setCatalogUpdated(typeof catalogDate === 'string' && catalogDate.trim() !== '' ? catalogDate : null)
1742
1758
  setHostVersion(typeof body.hostVersion === 'string' ? body.hostVersion : null)
1743
1759
  setLoadError(null)
1744
1760
  })
@@ -3579,7 +3595,7 @@ export function MarketSection(props: MarketSectionProps) {
3579
3595
  <div className={css.byline}>
3580
3596
  <OwnerAvatar name={p.name} owner={p.owner || ''} />
3581
3597
  <span className={css.owner} title={p.owner}>{p.owner}</span>
3582
- <CatalogVersionMark version={p.version} tip={t('catalogNpmLatest')} />
3598
+ <CatalogVersionMark version={p.version} tip={catalogVersionTip} />
3583
3599
  {typeof p.downloads === 'number' && (
3584
3600
  <Tooltip label={String(p.downloads)} side="top">
3585
3601
  <span className={css.star}>{'· ↓ ' + formatCount(p.downloads)}</span>
@@ -3721,7 +3737,7 @@ export function MarketSection(props: MarketSectionProps) {
3721
3737
  <div className={css.byline}>
3722
3738
  <OwnerAvatar name={p.name} owner={p.owner || ''} />
3723
3739
  <span className={css.owner} title={p.owner}>{p.owner}</span>
3724
- <CatalogVersionMark version={p.version} tip={t('catalogNpmLatest')} />
3740
+ <CatalogVersionMark version={p.version} tip={catalogVersionTip} />
3725
3741
  {typeof p.downloads === 'number' && (
3726
3742
  <Tooltip label={String(p.downloads)} side="top">
3727
3743
  <span className={css.star}>{'· ↓ ' + formatCount(p.downloads)}</span>
@@ -5473,7 +5489,7 @@ export function MarketSection(props: MarketSectionProps) {
5473
5489
  <div className={css.byline}>
5474
5490
  <OwnerAvatar name={confirming.name} owner={confirming.owner || ''} />
5475
5491
  <span className={css.owner} title={confirming.owner}>{confirming.owner}</span>
5476
- <CatalogVersionMark version={confirming.version} tip={t('catalogNpmLatest')} />
5492
+ <CatalogVersionMark version={confirming.version} tip={catalogVersionTip} />
5477
5493
  {typeof confirming.downloads === 'number' && (
5478
5494
  <Tooltip label={String(confirming.downloads)} side="top">
5479
5495
  <span className={css.star}>{'· ↓ ' + formatCount(confirming.downloads)}</span>
@@ -245,6 +245,11 @@ export const zh = {
245
245
  sortDownloads: 'npm 下载量(近 30 天)',
246
246
  /** Discover/theme card byline tooltip for catalog `version`. */
247
247
  catalogNpmLatest: 'npm 当前 latest',
248
+ // #712: the number is the CATALOG's copy, refreshed daily — not a live npm
249
+ // lookup. Saying "npm latest" beside a number that is not npm's latest is
250
+ // how a stale display turns into a bug report.
251
+ catalogVersionDated: '目录数据({0} 刷新)里记录的 npm 版本;在那之后发布的版本要等下一次目录刷新才会出现',
252
+ catalogVersionUndated: '目录数据里记录的 npm 版本;目录每日刷新,所以可能滞后于 npm 上的实际 latest',
248
253
  sortStars: 'Star 数',
249
254
  sortAdded: '发布时间',
250
255
  sortDesc: '降序',
@@ -841,6 +846,8 @@ export const en: Record<MarketKey, string> = {
841
846
  sortDownloads: 'npm downloads (30d)',
842
847
  /** Discover/theme card byline tooltip for catalog `version`. */
843
848
  catalogNpmLatest: 'npm latest',
849
+ catalogVersionDated: "the npm version as of the catalog's last refresh ({0}); a release published since then appears after the next refresh",
850
+ catalogVersionUndated: "the npm version as of the catalog's last refresh — the catalog updates daily, so this can lag npm's actual latest",
844
851
  sortStars: 'Stars',
845
852
  sortAdded: 'Release date',
846
853
  sortDesc: 'Descending',
package/src/dsh-cli.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * the agent's sandboxed executor and denies writes to the profile directory.
8
8
  */
9
9
 
10
- import { spawn } from 'node:child_process'
10
+ import { spawn, spawnSync } from 'node:child_process'
11
11
  import type { ChildProcess, SpawnOptions } from 'node:child_process'
12
12
  import { existsSync } from 'node:fs'
13
13
  import { homedir } from 'node:os'
@@ -217,19 +217,77 @@ export function toolSearchDirs(
217
217
  * precisely the branch that cannot work from a spawned child.
218
218
  *
219
219
  * Scope, stated plainly because it is narrower than the issue title
220
- * suggests: this is the HTTPS half. pnpm falls back to `git@github.com:`
221
- * when HTTPS fails, and the ssh side needs `GIT_SSH_COMMAND`, which
222
- * overrides `core.sshCommand` and `GIT_SSH` — the two ordinary ways to
223
- * choose an identity — and whose `BatchMode=yes` would disable
224
- * `SSH_ASKPASS`, breaking key-passphrase installs that work today. That
225
- * half needs a policy decision, so it is not made here. Note also that on
226
- * POSIX `runDshPlugin` spawns detached, so the subtree has no controlling
227
- * terminal and the prompt already dies instantly; the hang the issue
228
- * reports needs Windows, where the spawn is not detached.
220
+ * suggests: this is the HTTPS half, and the FIRST attempt HTTPS is. Measured
221
+ * with a `GIT_SSH_COMMAND` sentinel that always fails: pnpm's clone of a
222
+ * `github:owner/repo#path:/sub` spec succeeds without ever calling it, so
223
+ * ssh is the fallback that runs after HTTPS fails — which is the private
224
+ * repository case, not the common one.
225
+ *
226
+ * The ssh half (#596, @JINITAIMI121) is a policy decision, and it is made
227
+ * here: `BatchMode=yes`, so a passphrase or host-key question fails fast
228
+ * instead of waiting on a terminal nobody is watching. The cost is real and
229
+ * is why it is conditional — `BatchMode` also disables `SSH_ASKPASS`, so a
230
+ * key that NEEDS a passphrase stops prompting even where something could
231
+ * have answered, and the install fails where it used to work.
232
+ *
233
+ * So it is set only when the user has expressed NO ssh preference at all:
234
+ * `GIT_SSH_COMMAND` and `GIT_SSH` in the environment, and `core.sshCommand`
235
+ * in git config — the three ordinary ways to choose an identity, and
236
+ * `GIT_SSH_COMMAND` would silently override all three (measured: with
237
+ * `core.sshCommand` set, ours wins and theirs is never run). A user who
238
+ * has an agent or a specific key configured keeps exactly what they have;
239
+ * the prompt is closed for everyone else, and `classifyPnpmFailure` says
240
+ * what to do if that turns out to be the passphrase case.
241
+ */
242
+ let coreSshCommandProbe: string | null | undefined
243
+
244
+ /**
245
+ * `core.sshCommand` from the user's git configuration, or null.
246
+ *
247
+ * The third place an ssh identity hides, and the one the environment cannot
248
+ * show: `git config --get` answers it. Memoized for the process — a user
249
+ * does not reconfigure git mid-install, and this runs on every spawn.
250
+ *
251
+ * @returns the configured command, or null when git has none (including
252
+ * when git is absent — an unreadable answer is not a choice).
229
253
  */
230
- export function gitEnvForPnpm(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
231
- if ((env.GIT_TERMINAL_PROMPT ?? '').trim() !== '') return {}
232
- return { GIT_TERMINAL_PROMPT: '0' }
254
+ export function probeCoreSshCommand(env?: NodeJS.ProcessEnv): string | null {
255
+ // Memoized only for the caller that has no opinion: a test that supplies
256
+ // its own environment is asking a different question and must get the
257
+ // fresh answer.
258
+ if (env === undefined && coreSshCommandProbe !== undefined) return coreSshCommandProbe ?? null
259
+ try {
260
+ const out = spawnSync('git', ['config', '--get', 'core.sshCommand'], {
261
+ encoding: 'utf8',
262
+ timeout: 5_000,
263
+ stdio: ['ignore', 'pipe', 'ignore'],
264
+ ...(env === undefined ? {} : { env }),
265
+ // A user's git may be a shim that prints to a console; nothing here
266
+ // wants a window.
267
+ windowsHide: true,
268
+ })
269
+ const answer = out.status === 0 && typeof out.stdout === 'string' && out.stdout.trim() !== ''
270
+ ? out.stdout.trim()
271
+ : null
272
+ if (env === undefined) coreSshCommandProbe = answer
273
+ return answer
274
+ } catch {
275
+ if (env === undefined) coreSshCommandProbe = null
276
+ return null
277
+ }
278
+ }
279
+
280
+ export function gitEnvForPnpm(
281
+ env: NodeJS.ProcessEnv = process.env,
282
+ coreSshCommand: string | null = probeCoreSshCommand(),
283
+ ): NodeJS.ProcessEnv {
284
+ const out: NodeJS.ProcessEnv = {}
285
+ // Blank counts as unset: an empty value is not a setting git can parse.
286
+ const speaks = (value: string | undefined): boolean => (value ?? '').trim() !== ''
287
+ if (!speaks(env.GIT_TERMINAL_PROMPT)) out.GIT_TERMINAL_PROMPT = '0'
288
+ const choseElsewhere = speaks(env.GIT_SSH_COMMAND) || speaks(env.GIT_SSH) || speaks(coreSshCommand ?? '')
289
+ if (!choseElsewhere) out.GIT_SSH_COMMAND = 'ssh -oBatchMode=yes'
290
+ return out
233
291
  }
234
292
 
235
293
  /**
@@ -48,7 +48,7 @@ export interface PnpmFailure {
48
48
  | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch'
49
49
  | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout'
50
50
  | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked'
51
- | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom'
51
+ | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed'
52
52
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
53
53
  message: string
54
54
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
@@ -381,6 +381,21 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
381
381
  }
382
382
  }
383
383
  }
384
+ // #596: the ssh half of #587. git asks for a passphrase (or a host-key
385
+ // confirmation) on a terminal a spawned child does not have; the market
386
+ // closes that prompt with `BatchMode=yes` so the question becomes a fast
387
+ // failure instead of a fifteen-minute hang. This is that failure, and it
388
+ // needs its own message because git's own words send the reader to the
389
+ // wrong place: `Permission denied (publickey)` reads as "your key is
390
+ // wrong", and the key is usually fine — it wants a passphrase, and the
391
+ // channel that would have asked for it is exactly what was shut.
392
+ if (/Permission denied \(publickey\)|Could not read from remote repository/.test(output)) {
393
+ return {
394
+ code: 'ssh-auth-failed',
395
+ recoverable: false,
396
+ message: 'git 无法在无人值守的情况下完成 SSH 认证。如果你的 SSH key 设了密码,请用 ssh-agent(ssh-add),或自己设置 GIT_SSH_COMMAND 指向你的命令,市场不会覆盖它。 / git could not complete SSH authentication unattended. If your SSH key has a passphrase, use ssh-agent (ssh-add), or set GIT_SSH_COMMAND to your own command — the market leaves yours alone.',
397
+ }
398
+ }
384
399
  if (output.includes('ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED')) {
385
400
  return {
386
401
  code: 'git-prepare-not-allowed',