dshmarket 1.66.41 → 1.66.43

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/lib/changelog.js CHANGED
@@ -23,9 +23,9 @@
23
23
  import { fileFromTarball } from './catalog-npm.js';
24
24
  import { marketFetch } from './net.js';
25
25
  import { activeRegion, routesFor } from './regions.js';
26
- import { profileDir, readGitResolutionCommit, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.js';
26
+ import { profileDir, readGitResolutionCommit, readInstalled, readInstalledRepoEvidence, readInstalledVersion, readLockCommits } from './profile.js';
27
27
  import { githubRepoOfRemote, hostedRepoKey, isGitHostedSpec, lookupRepoFromUrl, parseHostShorthand, parseSourceUrl, repoOf, repoOfTarget } from './sources.js';
28
- import { checkUpdates } from './updates.js';
28
+ import { checkUpdates, compareVersions } from './updates.js';
29
29
  import { loadRegistry } from './registry.js';
30
30
  const UPDATES_PACKAGE = 'dsh-plugin-updates';
31
31
  const UPDATES_FILE = 'package/updates.json';
@@ -118,6 +118,20 @@ export function sliceCommitsAt(items, installed) {
118
118
  }
119
119
  return { items: out, found: false };
120
120
  }
121
+ /**
122
+ * The version a release tag names, when it names one.
123
+ *
124
+ * Tags come as `v1.2.3`, `1.2.3`, `@scope/pkg@1.2.3` or `pkg-v1.2.3`; the
125
+ * FIRST semver-shaped run is the version in all of them. A tag that names no
126
+ * version (`nightly`) returns null, and callers must treat "unknown" as "do
127
+ * not judge" rather than "older".
128
+ */
129
+ export function releaseVersionOf(tag) {
130
+ if (tag === null)
131
+ return null;
132
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(tag);
133
+ return match?.[1] ?? null;
134
+ }
121
135
  /** The repo url key an entry's update notes live under, or null. */
122
136
  export function repoKeyOf(spec) {
123
137
  const repo = githubIdentityOf(spec)?.split('#')[0] ?? null;
@@ -407,7 +421,23 @@ export async function updateNotesFor(profile, explicitDir, name) {
407
421
  current = statuses?.[name]?.current ?? null;
408
422
  }
409
423
  if (entry?.release !== undefined && entry.release !== null && (entry.release.body !== '' || entry.release.tag !== null)) {
410
- return { kind: 'release', release: entry.release };
424
+ // The release feed is a DAILY snapshot while the update target is read
425
+ // live from npm, so the snapshot's newest release is often the version
426
+ // already installed (or older). Showing it next to "1.66.39 → 1.66.40"
427
+ // describes a change the user already has (#925). When the release
428
+ // names a version no newer than the installed one AND a better tier
429
+ // exists, take the better tier: the commit tail is sliced at this
430
+ // install's sha, and npm publish times are newer than the install by
431
+ // construction. With nothing better, the stale release still beats an
432
+ // empty dialog.
433
+ const installed = readInstalledVersion(profile, name, activeProfileDir);
434
+ const released = releaseVersionOf(entry.release.tag);
435
+ const behind = installed !== null && released !== null
436
+ && (compareVersions(released, installed) ?? 0) <= 0;
437
+ const hasCommits = (entry.commits?.length ?? 0) > 0;
438
+ if (!(behind && (hasCommits || (specKey === null && npmName !== null)))) {
439
+ return { kind: 'release', release: entry.release };
440
+ }
411
441
  }
412
442
  if (entry?.commits !== undefined && entry.commits.length > 0) {
413
443
  return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) };
package/lib/install.js CHANGED
@@ -248,6 +248,15 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
248
248
  logEvent('warn', 'install', `transient network failure while pnpm replayed the dependency tree (#83) — retrying once`);
249
249
  result = await run(profile, pluginArgs);
250
250
  }
251
+ else if (failure?.code === 'exact-version-missing'
252
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove' || pluginArgs[0] === 'update')) {
253
+ // #927: market pinned name@x.y.z from /latest, but this registry's
254
+ // packument (often a mirror) does not list that version yet. Same argv
255
+ // once more gives pnpm a chance to re-fetch; if it still fails the
256
+ // classifier's message names "try later / switch Download region".
257
+ logEvent('warn', 'install', `exact version missing on this registry's packument (#927) — retrying once to re-fetch`);
258
+ result = await run(profile, pluginArgs);
259
+ }
251
260
  else if (failure?.code === 'fetch-timeout'
252
261
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
253
262
  && !pluginArgs.includes(FETCH_TIMEOUT_OVERRIDE)) {
@@ -662,7 +671,10 @@ export async function validateAddedPlugins(run, profile, before, explicitDir, ho
662
671
  const packageDir = join(dir, 'node_modules', n);
663
672
  // hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
664
673
  // ship no entry of their own (#103) and must not be uninstalled here.
665
- if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
674
+ // The host install tree is passed too (#906): a carrier mounting only
675
+ // in-box packages resolves nowhere under the profile, and judging it by
676
+ // that alone deleted a package the host had just installed correctly.
677
+ if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n, hostDirectory ?? findDshInstallDir())) {
666
678
  removedBroken.push(n);
667
679
  await removeAndReconcile(run, profile, dir, n, hostDirectory);
668
680
  continue;
@@ -0,0 +1,24 @@
1
+ import { homedir } from 'node:os';
2
+ /** Mask only this user's home prefix, leaving the rest of each path intact. */
3
+ export function redactHomePaths(text) {
4
+ const directory = homedir();
5
+ // Detect the home path's syntax rather than the host running the exporter.
6
+ // Windows is case-insensitive and accepts either separator, including mixed
7
+ // paths from pnpm. Repeated separators also cover JSON-escaped NDJSON history.
8
+ const windows = /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(directory);
9
+ const home = directory.replace(windows ? /[\\/]+$/ : /\/+$/, '');
10
+ // A POSIX root home must not turn every absolute path into a home path.
11
+ if (home === '')
12
+ return text;
13
+ const escape = (part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
14
+ const path = windows ? home.split(/[\\/]/).map(escape).join('[\\\\/]+') : escape(home);
15
+ // Do not match a home embedded in another path/text, or a sibling such as
16
+ // Alice-other or Alice.backup. File URLs still contain an absolute home;
17
+ // quotes/brackets allow bare homes in log prose.
18
+ const before = String.raw `(?:(?<![\p{L}\p{N}_./\\-])|(?<=\bfile://)|(?<=\bfile:///))`;
19
+ // Trimming C:\ leaves C:, but only an absolute path belongs to that root.
20
+ const after = windows && /^[a-z]:$/i.test(home)
21
+ ? String.raw `(?=[\\/])`
22
+ : windows ? String.raw `(?=$|[\\/\s"'\x60<>()[\]{},;:!?])` : String.raw `(?=$|[/\s"'\x60<>()[\]{},;:!?])`;
23
+ return text.replace(new RegExp(before + path + after, windows ? 'giu' : 'gu'), '~');
24
+ }
package/lib/log.js CHANGED
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
15
15
  import { dirname } from 'node:path';
16
- import { homedir } from 'node:os';
16
+ import { redactHomePaths } from './log-redaction.js';
17
17
  const MAX_ENTRIES = 200;
18
18
  const DETAIL_MAX = 600;
19
19
  const PERSISTENT_MAX_BYTES = 256 * 1024;
@@ -22,8 +22,7 @@ let persistentFile = null;
22
22
  /** Bytes in the sink file, tracked so the cap holds without a stat per event. */
23
23
  let persistentBytes = 0;
24
24
  function sanitize(text) {
25
- return text
26
- .replaceAll(homedir(), '~')
25
+ return redactHomePaths(text)
27
26
  // Log-injection guard: control characters (newlines above all) would
28
27
  // forge extra lines in the exported log file. The #98 routes pass
29
28
  // user-supplied names into logEvent (bundle-order order entries, trial
package/lib/patch.js CHANGED
@@ -115,6 +115,16 @@ export function findUserPatchPath(host, profileDir) {
115
115
  }
116
116
  /** Row ids the market is allowed to write: plain unquoted YAML scalars. */
117
117
  const ROW_ID_RE = /^[A-Za-z0-9_.-]+$/u;
118
+ function nextPatchContentLine(lines, from) {
119
+ let index = from;
120
+ while (index < lines.length) {
121
+ const line = (lines[index] ?? '').trim();
122
+ if (line !== '' && !line.startsWith('#'))
123
+ break;
124
+ index += 1;
125
+ }
126
+ return index;
127
+ }
118
128
  /**
119
129
  * Line-wise scan of one patch file — the plugin-hub shapes. Deliberately
120
130
  * not a YAML parse: the file may hold structures the market's dialect
@@ -146,14 +156,15 @@ export function readUserPatchState(patchPath) {
146
156
  inserts.push(insertRow[1]);
147
157
  continue;
148
158
  }
149
- const disableRow = /^- id: ([A-Za-z0-9_.-]+)\s*$/u.exec(line);
159
+ const disableRow = /^- id: (?:([A-Za-z0-9_.-]+)|'([A-Za-z0-9_.-]+)'|"([A-Za-z0-9_.-]+)")[ \t]*$/u.exec(line);
150
160
  if (disableRow === null)
151
161
  continue;
152
- const next = lines[index + 1] ?? '';
162
+ const id = (disableRow[1] ?? disableRow[2] ?? disableRow[3]);
163
+ const next = lines[nextPatchContentLine(lines, index + 1)] ?? '';
153
164
  if (/^ {2}disabled: true\s*$/u.test(next))
154
- disables.push(disableRow[1]);
165
+ disables.push(id);
155
166
  else if (/^ {2}disabled: false\s*$/u.test(next))
156
- forced.push(disableRow[1]);
167
+ forced.push(id);
157
168
  }
158
169
  return { disables, forced, inserts };
159
170
  }
@@ -429,16 +440,35 @@ function rowBlock(rowId, disabled) {
429
440
  return `- id: ${rowId}\n disabled: ${disabled ? 'true' : 'false'}\n`;
430
441
  }
431
442
  /**
432
- * Every two-line `- id: X` + `disabled: …` block for one row, in file order
433
- * (#821). A row that goes on with more keys (`config:` and the like) is not
434
- * one of these blocks: cutting its first two lines would leave the rest
435
- * hanging off the entry above it. The last block of a file may lack its
436
- * newline.
443
+ * Rewrite standalone id/flag rows while preserving the surrounding file.
444
+ * Comments and blank lines may separate the fields (#930). Look beyond them
445
+ * after the flag too: an indented config/name/key makes this a longer entry,
446
+ * whose first lines must never be removed (#821). Trailing comments stay put.
437
447
  */
438
- function rowBlocksRe(rowId, disabled) {
448
+ function rewriteRowBlocks(text, rowId, disabled, replace) {
439
449
  const id = escapeRegExp(rowId);
440
- const value = disabled === 'any' ? '(?:true|false)' : disabled;
441
- return new RegExp(`^- id: (?:${id}|'${id}'|"${id}")[ \\t]*\\r?\\n disabled: ${value}[ \\t]*(?:\\r?\\n(?![ \\t])|(?![\\s\\S]))`, 'gmu');
450
+ const idLine = new RegExp(`^- id: (?:${id}|'${id}'|"${id}")[ \t]*$`, 'u');
451
+ const lines = text.match(/[^\n]*(?:\n|$)/gu)?.filter(Boolean) ?? [];
452
+ const offsets = [0];
453
+ for (const line of lines)
454
+ offsets.push(offsets[offsets.length - 1] + line.length);
455
+ let result = '', copied = 0;
456
+ for (let index = 0; index < lines.length; index += 1) {
457
+ if (!idLine.test(lines[index].replace(/\r?\n$/u, '')))
458
+ continue;
459
+ const flagIndex = nextPatchContentLine(lines, index + 1);
460
+ const flag = /^ {2}disabled: (true|false)[ \t]*(?:\r?\n)?$/u.exec(lines[flagIndex] ?? '');
461
+ if (flag === null || (disabled !== 'any' && flag[1] !== disabled))
462
+ continue;
463
+ const following = nextPatchContentLine(lines, flagIndex + 1);
464
+ if (/^[ \t]/u.test(lines[following] ?? ''))
465
+ continue;
466
+ const start = offsets[index], end = offsets[flagIndex + 1];
467
+ result += text.slice(copied, start) + replace(text.slice(start, end));
468
+ copied = end;
469
+ index = flagIndex;
470
+ }
471
+ return result + text.slice(copied);
442
472
  }
443
473
  /**
444
474
  * What the patch file finally says about one row: the flag of its last
@@ -453,7 +483,7 @@ function lastRowFlag(text, rowId) {
453
483
  for (let index = 0; index < lines.length; index += 1) {
454
484
  if (!idLine.test(lines[index] ?? ''))
455
485
  continue;
456
- const value = /^ {2}disabled: (true|false)\s*$/u.exec(lines[index + 1] ?? '');
486
+ const value = /^ {2}disabled: (true|false)\s*$/u.exec(lines[nextPatchContentLine(lines, index + 1)] ?? '');
457
487
  if (value !== null)
458
488
  flag = value[1];
459
489
  }
@@ -561,16 +591,14 @@ export function disableRow(patchPath, rowId) {
561
591
  return '';
562
592
  }
563
593
  })();
564
- const blocks = text.match(rowBlocksRe(rowId, 'any')) ?? [];
565
- if (blocks.length > 1 || blocks.some(block => /disabled: false/u.test(block))) {
566
- let kept = false;
567
- const next = text.replace(rowBlocksRe(rowId, 'any'), block => {
568
- if (kept)
569
- return '';
570
- kept = true;
571
- const flipped = rowBlock(rowId, true);
572
- return block.includes('\r\n') ? flipped.replace(/\n/gu, '\r\n') : flipped;
573
- });
594
+ let kept = false;
595
+ const next = rewriteRowBlocks(text, rowId, 'any', block => {
596
+ if (kept)
597
+ return '';
598
+ kept = true;
599
+ return block.replace(/^ {2}disabled: false(?=[ \t]*(?:\r?\n|$))/mu, ' disabled: true');
600
+ });
601
+ if (next !== text) {
574
602
  const flipped = writePatchFile(patchPath, next);
575
603
  if (!flipped.ok)
576
604
  return flipped;
@@ -608,7 +636,7 @@ export function enableRow(patchPath, rowId) {
608
636
  return '';
609
637
  }
610
638
  })();
611
- const next = text.replace(rowBlocksRe(rowId, 'true'), '');
639
+ const next = rewriteRowBlocks(text, rowId, 'true', () => '');
612
640
  if (next !== text) {
613
641
  const written = writePatchFile(patchPath, withPlaceholderRestored(next));
614
642
  if (!written.ok)
@@ -654,7 +682,7 @@ export function removeRowBlocks(patchPath, rowIds) {
654
682
  } // no file, nothing to remove
655
683
  let next = text;
656
684
  for (const rowId of rowIds)
657
- next = next.replace(rowBlocksRe(rowId, 'any'), '');
685
+ next = rewriteRowBlocks(next, rowId, 'any', () => '');
658
686
  if (next === text)
659
687
  return { ok: true, reason: null };
660
688
  const written = writePatchFile(patchPath, withPlaceholderRestored(next));
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { existsSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
+ import { CHINA_NPM_REGISTRY } from './regions.js';
18
19
  /**
19
20
  * Decide the argv for a `dsh plugin <add|remove> …` call in the given profile.
20
21
  *
@@ -49,6 +50,20 @@ export const HOST_NAMESPACE_RE = /^@deepseek-ai\//;
49
50
  export function isTransientPnpmFailure(output) {
50
51
  return /ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL|FetchError|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|socket hang up|network timeout/i.test(output);
51
52
  }
53
+ /**
54
+ * China download route reset a package tarball mid-download (#926).
55
+ *
56
+ * Same-argv retry (#83) hits the same host again, so the generic "try again
57
+ * shortly" advice is wrong. Matched on ONE line: the China npm registry
58
+ * tarball URL and the reset must appear together — a China-mirror `.tgz`
59
+ * mention plus an unrelated `ECONNRESET` elsewhere (e.g. codeload) must not
60
+ * push the user to switch Download region.
61
+ */
62
+ function isChinaMirrorTarballReset(output) {
63
+ const hostPath = CHINA_NPM_REGISTRY.replace(/^https?:\/\//i, '');
64
+ const escaped = hostPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
65
+ return new RegExp(`${escaped}/\\S+/-/\\S+\\.tgz[^\\n]*(?:ECONNRESET|socket hang up)`, 'i').test(output);
66
+ }
52
67
  /**
53
68
  * The package a tarball URL belongs to: `…/@types/retry/-/retry-0.12.0.tgz`
54
69
  * → `@types/retry`, `…/lodash/-/lodash-4.17.21.tgz` → `lodash`. Scoped names
@@ -540,19 +555,49 @@ export function classifyPnpmFailure(output, exitCode) {
540
555
  // error code, so the classifier names it separately and the #289 retry
541
556
  // shares it via install.ts's gate. Real output (pnpm 12.4.1): see
542
557
  // tests/pnpm-compat.spec.ts, which pins this wording.
558
+ //
559
+ // #927: when the request is an EXACT version (name@1.2.3) that this
560
+ // registry cannot see yet — often a mirror whose `/latest` moved ahead of
561
+ // its packument — the generic "withdrawn or never published" sentence is
562
+ // wrong. That shape gets its own code so withHoistRecovery can retry the
563
+ // same argv once (re-fetch packument) and the UI can say "not synced yet".
543
564
  if (output.includes('ERR_PNPM_NO_MATCHING_VERSION')) {
544
- const pkg = /No matching version found for\s+((?:@[^/\s]+\/)?[^@\s]+)@/.exec(output)?.[1]
565
+ const asked = /No matching version found for\s+((?:@[^/\s]+\/)?[^@\s]+)@([^\s]+)/.exec(output);
566
+ const pkg = asked?.[1]
545
567
  ?? /GET\s+\S*\/([^/\s]+):/.exec(output)?.[1].replace(/%2[Ff]/g, '/');
568
+ const requested = asked?.[2];
546
569
  const zh = pkg === undefined ? '' : `(${pkg})`;
547
570
  const en = pkg === undefined ? '' : ` (${pkg})`;
548
571
  const hostPeer = pkg !== undefined && HOST_NAMESPACE_RE.test(pkg);
572
+ if (hostPeer) {
573
+ return {
574
+ code: 'no-matching-version',
575
+ recoverable: false,
576
+ pkg,
577
+ message: `插件声明依赖的宿主包${zh}在 registry 上没有满足其版本范围的发布版(宿主运行时会自带它,npm 上不会出现这个版本)。市场会自动重试一次,放行这条 peer 依赖 / a plugin's declared host peer${en} has no published version satisfying its range — the runtime provides it, so npm carries no such version. The market retries once with that peer exempted from auto-install`,
578
+ };
579
+ }
580
+ const exact = requested !== undefined
581
+ && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(requested);
582
+ if (exact) {
583
+ const pin = pkg === undefined ? requested : `${pkg}@${requested}`;
584
+ const latest = pkg === undefined
585
+ ? undefined
586
+ : new RegExp(`The latest release of\\s+${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+is\\s+"([^"]+)"`, 'i').exec(output)?.[1];
587
+ const latestZh = latest === undefined ? '' : `(它目前看到的最新是 ${latest})`;
588
+ const latestEn = latest === undefined ? '' : ` (it currently reports latest as ${latest})`;
589
+ return {
590
+ code: 'exact-version-missing',
591
+ recoverable: false,
592
+ ...(pkg === undefined ? {} : { pkg }),
593
+ message: `当前下载线路暂时找不到精确版本 ${pin}${latestZh}。常见原因是线路还没同步到刚发布的版本,不等于这个版本不存在。已自动重试一次仍失败——请稍后再试;若下载区域是中国大陆,也可改成「全球」后再试 / this download route cannot see the exact version ${pin} yet${latestEn}. Often the route has not synced a just-published release — that does not mean the version is missing from npm. One automatic retry failed too — try again shortly; if Download region is China mainland, you can also switch it to Global and retry`,
594
+ };
595
+ }
549
596
  return {
550
597
  code: 'no-matching-version',
551
598
  recoverable: false,
552
- pkg,
553
- message: hostPeer
554
- ? `插件声明依赖的宿主包${zh}在 registry 上没有满足其版本范围的发布版(宿主运行时会自带它,npm 上不会出现这个版本)。市场会自动重试一次,放行这条 peer 依赖 / a plugin's declared host peer${en} has no published version satisfying its range — the runtime provides it, so npm carries no such version. The market retries once with that peer exempted from auto-install`
555
- : `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
599
+ ...(pkg === undefined ? {} : { pkg }),
600
+ message: `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
556
601
  };
557
602
  }
558
603
  // #808 by @Tofu707: the profile's own pnpm-lock.yaml is what is wrong, in
@@ -706,6 +751,16 @@ export function classifyPnpmFailure(output, exitCode) {
706
751
  // a plain retry to succeed seconds later. withHoistRecovery retries once;
707
752
  // this message covers the case where the retry lost too.
708
753
  if (isTransientPnpmFailure(output)) {
754
+ // Keep code `transient-network` so withHoistRecovery still does the #83
755
+ // same-argv retry; only the final message changes when that retry lost
756
+ // against the China mirror's tarball host (#926).
757
+ if (isChinaMirrorTarballReset(output)) {
758
+ return {
759
+ code: 'transient-network',
760
+ recoverable: false,
761
+ message: '中国大陆下载线路在拉这个安装包时被断开了。再走同一条线路通常没用。打开设置,把「下载区域」改成「全球」,然后再装一次 / the China mainland download route was interrupted while fetching this package. Trying the same route again usually will not help. Open Settings, switch Download region to Global, and install again',
762
+ };
763
+ }
709
764
  return {
710
765
  code: 'transient-network',
711
766
  recoverable: false,
package/lib/profile.js CHANGED
@@ -4,9 +4,11 @@
4
4
  * functions of the directory contents; no processes, no network.
5
5
  */
6
6
  import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
7
+ import { createRequire } from 'node:module';
7
8
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
8
9
  import { isDeepStrictEqual } from 'node:util';
9
10
  import { JSON_SCHEMA, load } from 'js-yaml';
11
+ import { findDshInstallDir } from './dsh-install.js';
10
12
  import { resolveDshHome } from './home-paths.js';
11
13
  import { githubRemoteIdentities, githubRepoIdentities, hostedRepoKey, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.js';
12
14
  /**
@@ -1296,7 +1298,7 @@ export function conflictingEntryIds(profileDirectory, candidate, installedBundle
1296
1298
  * @param profileDirectory - resolved profile directory (host-authoritative under Desktop).
1297
1299
  * @param name - installed package name.
1298
1300
  */
1299
- export function hasLoadableEntry(profileDirectory, name) {
1301
+ export function hasLoadableEntry(profileDirectory, name, hostDirectory) {
1300
1302
  const dir = join(profileDirectory, 'node_modules', name);
1301
1303
  if (entryArtifactExists(dir))
1302
1304
  return true;
@@ -1310,11 +1312,41 @@ export function hasLoadableEntry(profileDirectory, name) {
1310
1312
  // exiting 0 was immediately followed by the market removing what it had
1311
1313
  // just, correctly, installed.
1312
1314
  const workspaceRoot = dirname(profileDirectory);
1313
- return bundlePatchTargets(dir)
1314
- .filter(target => target !== name)
1315
- .some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
1315
+ const targets = bundlePatchTargets(dir).filter(target => target !== name);
1316
+ if (targets.some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
1316
1317
  || entryArtifactExists(join(dir, 'node_modules', target))
1317
- || entryArtifactExists(join(workspaceRoot, 'node_modules', target)));
1318
+ || entryArtifactExists(join(workspaceRoot, 'node_modules', target))))
1319
+ return true;
1320
+ // #906: the host's OWN packages live in its install tree, not in any
1321
+ // profile node_modules — `<install>/dsh/node_modules/...`, often inside
1322
+ // app.asar. A carrier whose patch mounts nothing but in-box packages
1323
+ // (@deepseek-ai/dsh-mcp-client and the like) therefore failed every root
1324
+ // above, was reported "broken", and — through validateAddedPlugins — was
1325
+ // uninstalled right after a successful install. `undefined` resolves the
1326
+ // install dir lazily (only carriers pay for it); `null` means "do not look".
1327
+ //
1328
+ // Inside app.asar the probe is blind for EVERY @deepseek-ai/ name (the
1329
+ // same measurement behind #676), so there identity decides instead of the
1330
+ // filesystem — the host publishes that scope, and our own composer loads
1331
+ // those packages from the archive at boot.
1332
+ const host = hostDirectory === undefined ? findDshInstallDir() : hostDirectory;
1333
+ if (host === null || targets.length === 0)
1334
+ return false;
1335
+ const packaged = /[/\\]app\.asar[/\\]/iu.test(host);
1336
+ return targets.some(target => packaged && target.startsWith('@deepseek-ai/')
1337
+ || packageResolvableFrom(join(host, 'package.json'), target));
1338
+ }
1339
+ /** Whether `target` resolves from the host's own dependency tree. */
1340
+ function packageResolvableFrom(anchorPackageJson, target) {
1341
+ try {
1342
+ const paths = createRequire(anchorPackageJson).resolve.paths(target) ?? [];
1343
+ return paths.some(searchPath => existsSync(join(searchPath, target, 'package.json')));
1344
+ }
1345
+ catch {
1346
+ // An unreadable anchor says nothing about the target: treat as absent
1347
+ // rather than loadable, so the caller keeps its conservative answer.
1348
+ return false;
1349
+ }
1318
1350
  }
1319
1351
  /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
1320
1352
  export function pluginSubdirs(root) {
package/lib/recovery.js CHANGED
@@ -32,10 +32,10 @@ import { spawn } from 'node:child_process';
32
32
  import { appendFileSync, openSync, readFileSync } from 'node:fs';
33
33
  import { createServer } from 'node:http';
34
34
  import { connect } from 'node:net';
35
- import { homedir } from 'node:os';
36
35
  import { resolve } from 'node:path';
37
36
  import { fileURLToPath } from 'node:url';
38
37
  import { readJsonBody, sameOrigin, sendJson } from './http.js';
38
+ import { redactHomePaths } from './log-redaction.js';
39
39
  import { disableRow, enableRow } from './patch.js';
40
40
  import { addProfileBundle, removeProfileBundle } from './profile.js';
41
41
  /** How long the replacement gets to bind the port before it is a failure. */
@@ -464,8 +464,7 @@ async function waitForPortFree(port, timeoutMs) {
464
464
  * one unreadable line, which is the opposite of what the export is for.
465
465
  */
466
466
  function redactLog(text) {
467
- return text
468
- .replaceAll(homedir(), '~')
467
+ return redactHomePaths(text)
469
468
  .replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-***')
470
469
  .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, 'gh*_***')
471
470
  .replace(/npm_[A-Za-z0-9]{16,}/g, 'npm_***')
package/lib/regions.js CHANGED
@@ -32,7 +32,14 @@ export function asRegion(value) {
32
32
  * pnpm's registry alone and setting it.
33
33
  */
34
34
  export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
35
- const NPM_CHINA = 'https://mirrors.cloud.tencent.com/npm';
35
+ /**
36
+ * Default npm registry for the China download region, no trailing slash.
37
+ *
38
+ * Exported so failure classifiers can recognize this host in pnpm's output
39
+ * (#926) without duplicating the URL.
40
+ */
41
+ export const CHINA_NPM_REGISTRY = 'https://mirrors.cloud.tencent.com/npm';
42
+ const NPM_CHINA = CHINA_NPM_REGISTRY;
36
43
  /**
37
44
  * First public prefix used for unauthenticated GitHub reads, no trailing slash.
38
45
  * Authenticated API calls and canonical codeload tarballs never use it.
package/lib/routes.js CHANGED
@@ -4939,6 +4939,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4939
4939
  if (droppedBundles.length > 0) {
4940
4940
  logEvent('warn', 'update-bundles-dropped', `${name}: after this update ${droppedBundles.join(', ')} ${droppedBundles.length === 1 ? 'is' : 'are'} still declared in dependencies but no longer in dsh.profile.bundles, so ${droppedBundles.length === 1 ? 'it' : 'they'} will not load — the market did not write that change (#720)`);
4941
4941
  }
4942
+ // #935: the desktop manager's removeBundle writes the bundle row
4943
+ // out FIRST and only then checks whether a live fiber still uses
4944
+ // the package — so a `bundle-in-use` refusal leaves the row gone
4945
+ // while dependencies and node_modules stay. The plugin keeps
4946
+ // running this session and silently fails to load next boot.
4947
+ // Restore the row, scoped to exactly that refusal and to this
4948
+ // operation's own target: the read-only stance in #720/#696
4949
+ // still holds for every other way a row can disappear.
4950
+ if (!ok && !cancelled && result.refusalCode === 'bundle-in-use') {
4951
+ const targetRowDropped = bundlesDroppedFromProfile(manifestBefore, config.profile, activeProfileDir, new Set(removedDeclaration === null ? [] : [removedDeclaration.name])).includes(name);
4952
+ if (targetRowDropped && addProfileBundle(activeProfileDir, name)) {
4953
+ logEvent('warn', 'update-bundles-restored', `${name}: the desktop app removed its bundle row before refusing the update (bundle-in-use) — restored it so the plugin still loads on the next boot (#935)`);
4954
+ }
4955
+ }
4942
4956
  logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ` STALE(${staleReason ?? 'unknown'})` : ''}${ok || cancelled ? '' : ` err=${failureDetail(result)}`}`);
4943
4957
  // A user-cancelled run is a quiet outcome, not an error.
4944
4958
  sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
@@ -6128,7 +6142,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
6128
6142
  // (a 404 for this package's own download: the classifier names
6129
6143
  // the last path segment, the tarball file for that URL form).
6130
6144
  const ownTarball = `${plainTarget.slice(plainTarget.lastIndexOf('/') + 1)}-${String(registryLatest)}.tgz`;
6131
- const notOnMirror = (failure?.code === 'no-matching-version' && aboutThisPackage)
6145
+ // `exact-version-missing` (#927) is the same lag shape with a
6146
+ // clearer sentence; install still falls back to the bare name
6147
+ // after withHoistRecovery's one same-argv retry loses.
6148
+ const notOnMirror = ((failure?.code === 'no-matching-version' || failure?.code === 'exact-version-missing')
6149
+ && aboutThisPackage)
6132
6150
  || (failure?.code === 'fetch-404' && (failure.pkg === plainTarget || failure.pkg === ownTarball));
6133
6151
  if (heldBack || notOnMirror) {
6134
6152
  // The user asked for the young release anyway. That request is
@@ -63,6 +63,15 @@ export declare function sliceCommitsAt(items: CommitNote[], installed: string |
63
63
  items: CommitNote[];
64
64
  found: boolean;
65
65
  };
66
+ /**
67
+ * The version a release tag names, when it names one.
68
+ *
69
+ * Tags come as `v1.2.3`, `1.2.3`, `@scope/pkg@1.2.3` or `pkg-v1.2.3`; the
70
+ * FIRST semver-shaped run is the version in all of them. A tag that names no
71
+ * version (`nightly`) returns null, and callers must treat "unknown" as "do
72
+ * not judge" rather than "older".
73
+ */
74
+ export declare function releaseVersionOf(tag: string | null): string | null;
66
75
  /** The repo url key an entry's update notes live under, or null. */
67
76
  export declare function repoKeyOf(spec: string): string | null;
68
77
  /**
@@ -0,0 +1,2 @@
1
+ /** Mask only this user's home prefix, leaving the rest of each path intact. */
2
+ export declare function redactHomePaths(text: string): string;
@@ -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' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked' | 'profile-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed' | 'host-incompatible-install' | 'lockfile-broken' | 'lockfile-outdated' | 'pnpm-stalled' | 'profile-write-lock';
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' | 'exact-version-missing' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked' | 'profile-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed' | 'host-incompatible-install' | 'lockfile-broken' | 'lockfile-outdated' | 'pnpm-stalled' | 'profile-write-lock';
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. */
@@ -412,7 +412,7 @@ export declare function conflictingEntryIds(profileDirectory: string, candidate:
412
412
  * @param profileDirectory - resolved profile directory (host-authoritative under Desktop).
413
413
  * @param name - installed package name.
414
414
  */
415
- export declare function hasLoadableEntry(profileDirectory: string, name: string): boolean;
415
+ export declare function hasLoadableEntry(profileDirectory: string, name: string, hostDirectory?: string | null): boolean;
416
416
  /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
417
417
  export declare function pluginSubdirs(root: string): string[];
418
418
  /**
@@ -32,6 +32,13 @@ export declare function asRegion(value: unknown): Region | null;
32
32
  * pnpm's registry alone and setting it.
33
33
  */
34
34
  export declare const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
35
+ /**
36
+ * Default npm registry for the China download region, no trailing slash.
37
+ *
38
+ * Exported so failure classifiers can recognize this host in pnpm's output
39
+ * (#926) without duplicating the URL.
40
+ */
41
+ export declare const CHINA_NPM_REGISTRY = "https://mirrors.cloud.tencent.com/npm";
35
42
  /** GitHub transports that fail independently on filtered networks. */
36
43
  export type GithubService = 'git' | 'raw' | 'avatar';
37
44
  /** A prefix proxy, or null for the canonical GitHub address. */
package/lib/updates.js CHANGED
@@ -289,7 +289,22 @@ async function tagVersion(name, tag) {
289
289
  export async function fetchNpmLatest(name) {
290
290
  try {
291
291
  const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`)));
292
- return typeof meta.version === 'string' ? meta.version : null;
292
+ if (typeof meta.version === 'string')
293
+ return meta.version;
294
+ }
295
+ catch {
296
+ // Fall through to the full packument (#932).
297
+ }
298
+ // #932: the `/latest` shortcut can fail on its own — measured HTTP 429 from
299
+ // registry.npmjs.org while the full packument answered 200 with the same
300
+ // dist-tag. Returning null there made a fresh install fall back to the bare
301
+ // name, and pnpm's fresh-release hold then installed an OLDER version than
302
+ // the catalog and the confirmation screen had just shown. The packument is
303
+ // a bigger read, so it is only asked when the shortcut failed.
304
+ try {
305
+ const doc = (await fetchJson(npmUrl(encodeURIComponent(name))));
306
+ const latest = doc['dist-tags']?.latest;
307
+ return typeof latest === 'string' ? latest : null;
293
308
  }
294
309
  catch {
295
310
  return null;
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.66.41",
4
+ "version": "1.66.43",
5
5
  "icon": "assets/logo.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
package/src/changelog.ts CHANGED
@@ -24,9 +24,9 @@
24
24
  import { fileFromTarball } from './catalog-npm.ts'
25
25
  import { marketFetch } from './net.ts'
26
26
  import { activeRegion, routesFor } from './regions.ts'
27
- import { profileDir, readGitResolutionCommit, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.ts'
27
+ import { profileDir, readGitResolutionCommit, readInstalled, readInstalledRepoEvidence, readInstalledVersion, readLockCommits } from './profile.ts'
28
28
  import { githubRepoOfRemote, hostedRepoKey, isGitHostedSpec, lookupRepoFromUrl, parseHostShorthand, parseSourceUrl, repoOf, repoOfTarget } from './sources.ts'
29
- import { checkUpdates } from './updates.ts'
29
+ import { checkUpdates, compareVersions } from './updates.ts'
30
30
  import { loadRegistry, type RegistryPlugin } from './registry.ts'
31
31
 
32
32
  const UPDATES_PACKAGE = 'dsh-plugin-updates'
@@ -141,6 +141,20 @@ export function sliceCommitsAt(items: CommitNote[], installed: string | null): {
141
141
  return { items: out, found: false }
142
142
  }
143
143
 
144
+ /**
145
+ * The version a release tag names, when it names one.
146
+ *
147
+ * Tags come as `v1.2.3`, `1.2.3`, `@scope/pkg@1.2.3` or `pkg-v1.2.3`; the
148
+ * FIRST semver-shaped run is the version in all of them. A tag that names no
149
+ * version (`nightly`) returns null, and callers must treat "unknown" as "do
150
+ * not judge" rather than "older".
151
+ */
152
+ export function releaseVersionOf(tag: string | null): string | null {
153
+ if (tag === null) return null
154
+ const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(tag)
155
+ return match?.[1] ?? null
156
+ }
157
+
144
158
  /** The repo url key an entry's update notes live under, or null. */
145
159
  export function repoKeyOf(spec: string): string | null {
146
160
  const repo = githubIdentityOf(spec)?.split('#')[0] ?? null
@@ -450,7 +464,23 @@ export async function updateNotesFor(
450
464
  }
451
465
 
452
466
  if (entry?.release !== undefined && entry.release !== null && (entry.release.body !== '' || entry.release.tag !== null)) {
453
- return { kind: 'release', release: entry.release }
467
+ // The release feed is a DAILY snapshot while the update target is read
468
+ // live from npm, so the snapshot's newest release is often the version
469
+ // already installed (or older). Showing it next to "1.66.39 → 1.66.40"
470
+ // describes a change the user already has (#925). When the release
471
+ // names a version no newer than the installed one AND a better tier
472
+ // exists, take the better tier: the commit tail is sliced at this
473
+ // install's sha, and npm publish times are newer than the install by
474
+ // construction. With nothing better, the stale release still beats an
475
+ // empty dialog.
476
+ const installed = readInstalledVersion(profile, name, activeProfileDir)
477
+ const released = releaseVersionOf(entry.release.tag)
478
+ const behind = installed !== null && released !== null
479
+ && (compareVersions(released, installed) ?? 0) <= 0
480
+ const hasCommits = (entry.commits?.length ?? 0) > 0
481
+ if (!(behind && (hasCommits || (specKey === null && npmName !== null)))) {
482
+ return { kind: 'release', release: entry.release }
483
+ }
454
484
  }
455
485
  if (entry?.commits !== undefined && entry.commits.length > 0) {
456
486
  return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) }
package/src/install.ts CHANGED
@@ -256,6 +256,16 @@ export async function withHoistRecovery(
256
256
  // Do that retry ourselves instead of reporting a false failure.
257
257
  logEvent('warn', 'install', `transient network failure while pnpm replayed the dependency tree (#83) — retrying once`)
258
258
  result = await run(profile, pluginArgs)
259
+ } else if (
260
+ failure?.code === 'exact-version-missing'
261
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove' || pluginArgs[0] === 'update')
262
+ ) {
263
+ // #927: market pinned name@x.y.z from /latest, but this registry's
264
+ // packument (often a mirror) does not list that version yet. Same argv
265
+ // once more gives pnpm a chance to re-fetch; if it still fails the
266
+ // classifier's message names "try later / switch Download region".
267
+ logEvent('warn', 'install', `exact version missing on this registry's packument (#927) — retrying once to re-fetch`)
268
+ result = await run(profile, pluginArgs)
259
269
  } else if (
260
270
  failure?.code === 'fetch-timeout'
261
271
  && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
@@ -659,7 +669,10 @@ export async function validateAddedPlugins(
659
669
  const packageDir = join(dir, 'node_modules', n)
660
670
  // hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
661
671
  // ship no entry of their own (#103) and must not be uninstalled here.
662
- if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
672
+ // The host install tree is passed too (#906): a carrier mounting only
673
+ // in-box packages resolves nowhere under the profile, and judging it by
674
+ // that alone deleted a package the host had just installed correctly.
675
+ if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n, hostDirectory ?? findDshInstallDir())) {
663
676
  removedBroken.push(n)
664
677
  await removeAndReconcile(run, profile, dir, n, hostDirectory)
665
678
  continue
@@ -0,0 +1,24 @@
1
+ import { homedir } from 'node:os'
2
+
3
+ /** Mask only this user's home prefix, leaving the rest of each path intact. */
4
+ export function redactHomePaths(text: string): string {
5
+ const directory = homedir()
6
+ // Detect the home path's syntax rather than the host running the exporter.
7
+ // Windows is case-insensitive and accepts either separator, including mixed
8
+ // paths from pnpm. Repeated separators also cover JSON-escaped NDJSON history.
9
+ const windows = /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(directory)
10
+ const home = directory.replace(windows ? /[\\/]+$/ : /\/+$/, '')
11
+ // A POSIX root home must not turn every absolute path into a home path.
12
+ if (home === '') return text
13
+ const escape = (part: string): string => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
14
+ const path = windows ? home.split(/[\\/]/).map(escape).join('[\\\\/]+') : escape(home)
15
+ // Do not match a home embedded in another path/text, or a sibling such as
16
+ // Alice-other or Alice.backup. File URLs still contain an absolute home;
17
+ // quotes/brackets allow bare homes in log prose.
18
+ const before = String.raw`(?:(?<![\p{L}\p{N}_./\\-])|(?<=\bfile://)|(?<=\bfile:///))`
19
+ // Trimming C:\ leaves C:, but only an absolute path belongs to that root.
20
+ const after = windows && /^[a-z]:$/i.test(home)
21
+ ? String.raw`(?=[\\/])`
22
+ : windows ? String.raw`(?=$|[\\/\s"'\x60<>()[\]{},;:!?])` : String.raw`(?=$|[/\s"'\x60<>()[\]{},;:!?])`
23
+ return text.replace(new RegExp(before + path + after, windows ? 'giu' : 'gu'), '~')
24
+ }
package/src/log.ts CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
16
16
  import { dirname } from 'node:path'
17
- import { homedir } from 'node:os'
17
+ import { redactHomePaths } from './log-redaction.ts'
18
18
 
19
19
  export type LogLevel = 'info' | 'warn' | 'error'
20
20
 
@@ -35,8 +35,7 @@ let persistentFile: string | null = null
35
35
  let persistentBytes = 0
36
36
 
37
37
  function sanitize(text: string): string {
38
- return text
39
- .replaceAll(homedir(), '~')
38
+ return redactHomePaths(text)
40
39
  // Log-injection guard: control characters (newlines above all) would
41
40
  // forge extra lines in the exported log file. The #98 routes pass
42
41
  // user-supplied names into logEvent (bundle-order order entries, trial
package/src/patch.ts CHANGED
@@ -137,6 +137,16 @@ export interface PatchState {
137
137
  /** Row ids the market is allowed to write: plain unquoted YAML scalars. */
138
138
  const ROW_ID_RE = /^[A-Za-z0-9_.-]+$/u
139
139
 
140
+ function nextPatchContentLine(lines: readonly string[], from: number): number {
141
+ let index = from
142
+ while (index < lines.length) {
143
+ const line = (lines[index] ?? '').trim()
144
+ if (line !== '' && !line.startsWith('#')) break
145
+ index += 1
146
+ }
147
+ return index
148
+ }
149
+
140
150
  /**
141
151
  * Line-wise scan of one patch file — the plugin-hub shapes. Deliberately
142
152
  * not a YAML parse: the file may hold structures the market's dialect
@@ -165,11 +175,12 @@ export function readUserPatchState(patchPath: string): PatchState {
165
175
  if (insertRow !== null) inserts.push(insertRow[1])
166
176
  continue
167
177
  }
168
- const disableRow = /^- id: ([A-Za-z0-9_.-]+)\s*$/u.exec(line)
178
+ const disableRow = /^- id: (?:([A-Za-z0-9_.-]+)|'([A-Za-z0-9_.-]+)'|"([A-Za-z0-9_.-]+)")[ \t]*$/u.exec(line)
169
179
  if (disableRow === null) continue
170
- const next = lines[index + 1] ?? ''
171
- if (/^ {2}disabled: true\s*$/u.test(next)) disables.push(disableRow[1])
172
- else if (/^ {2}disabled: false\s*$/u.test(next)) forced.push(disableRow[1])
180
+ const id = (disableRow[1] ?? disableRow[2] ?? disableRow[3])!
181
+ const next = lines[nextPatchContentLine(lines, index + 1)] ?? ''
182
+ if (/^ {2}disabled: true\s*$/u.test(next)) disables.push(id)
183
+ else if (/^ {2}disabled: false\s*$/u.test(next)) forced.push(id)
173
184
  }
174
185
  return { disables, forced, inserts }
175
186
  }
@@ -436,16 +447,31 @@ function rowBlock(rowId: string, disabled: boolean): string {
436
447
  }
437
448
 
438
449
  /**
439
- * Every two-line `- id: X` + `disabled: …` block for one row, in file order
440
- * (#821). A row that goes on with more keys (`config:` and the like) is not
441
- * one of these blocks: cutting its first two lines would leave the rest
442
- * hanging off the entry above it. The last block of a file may lack its
443
- * newline.
450
+ * Rewrite standalone id/flag rows while preserving the surrounding file.
451
+ * Comments and blank lines may separate the fields (#930). Look beyond them
452
+ * after the flag too: an indented config/name/key makes this a longer entry,
453
+ * whose first lines must never be removed (#821). Trailing comments stay put.
444
454
  */
445
- function rowBlocksRe(rowId: string, disabled: 'true' | 'false' | 'any'): RegExp {
455
+ function rewriteRowBlocks(text: string, rowId: string, disabled: 'true' | 'false' | 'any', replace: (block: string) => string): string {
446
456
  const id = escapeRegExp(rowId)
447
- const value = disabled === 'any' ? '(?:true|false)' : disabled
448
- return new RegExp(`^- id: (?:${id}|'${id}'|"${id}")[ \\t]*\\r?\\n disabled: ${value}[ \\t]*(?:\\r?\\n(?![ \\t])|(?![\\s\\S]))`, 'gmu')
457
+ const idLine = new RegExp(`^- id: (?:${id}|'${id}'|"${id}")[ \t]*$`, 'u')
458
+ const lines = text.match(/[^\n]*(?:\n|$)/gu)?.filter(Boolean) ?? []
459
+ const offsets: number[] = [0]
460
+ for (const line of lines) offsets.push(offsets[offsets.length - 1]! + line.length)
461
+ let result = '', copied = 0
462
+ for (let index = 0; index < lines.length; index += 1) {
463
+ if (!idLine.test(lines[index]!.replace(/\r?\n$/u, ''))) continue
464
+ const flagIndex = nextPatchContentLine(lines, index + 1)
465
+ const flag = /^ {2}disabled: (true|false)[ \t]*(?:\r?\n)?$/u.exec(lines[flagIndex] ?? '')
466
+ if (flag === null || (disabled !== 'any' && flag[1] !== disabled)) continue
467
+ const following = nextPatchContentLine(lines, flagIndex + 1)
468
+ if (/^[ \t]/u.test(lines[following] ?? '')) continue
469
+ const start = offsets[index]!, end = offsets[flagIndex + 1]!
470
+ result += text.slice(copied, start) + replace(text.slice(start, end))
471
+ copied = end
472
+ index = flagIndex
473
+ }
474
+ return result + text.slice(copied)
449
475
  }
450
476
 
451
477
  /**
@@ -460,7 +486,7 @@ function lastRowFlag(text: string, rowId: string): 'true' | 'false' | null {
460
486
  let flag: 'true' | 'false' | null = null
461
487
  for (let index = 0; index < lines.length; index += 1) {
462
488
  if (!idLine.test(lines[index] ?? '')) continue
463
- const value = /^ {2}disabled: (true|false)\s*$/u.exec(lines[index + 1] ?? '')
489
+ const value = /^ {2}disabled: (true|false)\s*$/u.exec(lines[nextPatchContentLine(lines, index + 1)] ?? '')
464
490
  if (value !== null) flag = value[1] as 'true' | 'false'
465
491
  }
466
492
  return flag
@@ -563,15 +589,13 @@ export function disableRow(patchPath: string, rowId: string): Promise<{ ok: bool
563
589
  const text = (() => {
564
590
  try { return readFileSync(patchPath, 'utf8') } catch { return '' }
565
591
  })()
566
- const blocks = text.match(rowBlocksRe(rowId, 'any')) ?? []
567
- if (blocks.length > 1 || blocks.some(block => /disabled: false/u.test(block))) {
568
- let kept = false
569
- const next = text.replace(rowBlocksRe(rowId, 'any'), block => {
570
- if (kept) return ''
571
- kept = true
572
- const flipped = rowBlock(rowId, true)
573
- return block.includes('\r\n') ? flipped.replace(/\n/gu, '\r\n') : flipped
574
- })
592
+ let kept = false
593
+ const next = rewriteRowBlocks(text, rowId, 'any', block => {
594
+ if (kept) return ''
595
+ kept = true
596
+ return block.replace(/^ {2}disabled: false(?=[ \t]*(?:\r?\n|$))/mu, ' disabled: true')
597
+ })
598
+ if (next !== text) {
575
599
  const flipped = writePatchFile(patchPath, next)
576
600
  if (!flipped.ok) return flipped
577
601
  // A longer entry for the row further down still has the last word.
@@ -601,7 +625,7 @@ export function enableRow(patchPath: string, rowId: string): Promise<{ ok: boole
601
625
  const text = (() => {
602
626
  try { return readFileSync(patchPath, 'utf8') } catch { return '' }
603
627
  })()
604
- const next = text.replace(rowBlocksRe(rowId, 'true'), '')
628
+ const next = rewriteRowBlocks(text, rowId, 'true', () => '')
605
629
  if (next !== text) {
606
630
  const written = writePatchFile(patchPath, withPlaceholderRestored(next))
607
631
  if (!written.ok) return written
@@ -640,7 +664,7 @@ export function removeRowBlocks(patchPath: string, rowIds: readonly string[]): {
640
664
  text = readFileSync(patchPath, 'utf8')
641
665
  } catch { return { ok: true, reason: null } } // no file, nothing to remove
642
666
  let next = text
643
- for (const rowId of rowIds) next = next.replace(rowBlocksRe(rowId, 'any'), '')
667
+ for (const rowId of rowIds) next = rewriteRowBlocks(next, rowId, 'any', () => '')
644
668
  if (next === text) return { ok: true, reason: null }
645
669
  const written = writePatchFile(patchPath, withPlaceholderRestored(next))
646
670
  if (written.ok) logEvent('info', 'patch', `removed patch rows for ${rowIds.join(', ')}`)
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { existsSync } from 'node:fs'
18
18
  import { join } from 'node:path'
19
-
19
+ import { CHINA_NPM_REGISTRY } from './regions.ts'
20
20
 
21
21
  /**
22
22
  * Decide the argv for a `dsh plugin <add|remove> …` call in the given profile.
@@ -46,7 +46,7 @@ export const HOST_NAMESPACE_RE = /^@deepseek-ai\//
46
46
  export interface PnpmFailure {
47
47
  code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation'
48
48
  | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch'
49
- | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout'
49
+ | 'fetch-404' | 'no-matching-version' | 'exact-version-missing' | 'transient-network' | 'fetch-timeout'
50
50
  | 'unexpected-store' | 'patch-failed' | 'unused-patch' | 'missing-tarball-integrity' | 'windows-file-locked'
51
51
  | 'profile-file-locked'
52
52
  | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed'
@@ -89,6 +89,24 @@ export function isTransientPnpmFailure(output: string): boolean {
89
89
  return /ERR_PNPM_FETCH_5\d\d|ERR_PNPM_META_FETCH_FAIL|FetchError|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|socket hang up|network timeout/i.test(output)
90
90
  }
91
91
 
92
+ /**
93
+ * China download route reset a package tarball mid-download (#926).
94
+ *
95
+ * Same-argv retry (#83) hits the same host again, so the generic "try again
96
+ * shortly" advice is wrong. Matched on ONE line: the China npm registry
97
+ * tarball URL and the reset must appear together — a China-mirror `.tgz`
98
+ * mention plus an unrelated `ECONNRESET` elsewhere (e.g. codeload) must not
99
+ * push the user to switch Download region.
100
+ */
101
+ function isChinaMirrorTarballReset(output: string): boolean {
102
+ const hostPath = CHINA_NPM_REGISTRY.replace(/^https?:\/\//i, '')
103
+ const escaped = hostPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
104
+ return new RegExp(
105
+ `${escaped}/\\S+/-/\\S+\\.tgz[^\\n]*(?:ECONNRESET|socket hang up)`,
106
+ 'i',
107
+ ).test(output)
108
+ }
109
+
92
110
  /**
93
111
  * The package a tarball URL belongs to: `…/@types/retry/-/retry-0.12.0.tgz`
94
112
  * → `@types/retry`, `…/lodash/-/lodash-4.17.21.tgz` → `lodash`. Scoped names
@@ -578,19 +596,52 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
578
596
  // error code, so the classifier names it separately and the #289 retry
579
597
  // shares it via install.ts's gate. Real output (pnpm 12.4.1): see
580
598
  // tests/pnpm-compat.spec.ts, which pins this wording.
599
+ //
600
+ // #927: when the request is an EXACT version (name@1.2.3) that this
601
+ // registry cannot see yet — often a mirror whose `/latest` moved ahead of
602
+ // its packument — the generic "withdrawn or never published" sentence is
603
+ // wrong. That shape gets its own code so withHoistRecovery can retry the
604
+ // same argv once (re-fetch packument) and the UI can say "not synced yet".
581
605
  if (output.includes('ERR_PNPM_NO_MATCHING_VERSION')) {
582
- const pkg = /No matching version found for\s+((?:@[^/\s]+\/)?[^@\s]+)@/.exec(output)?.[1]
606
+ const asked = /No matching version found for\s+((?:@[^/\s]+\/)?[^@\s]+)@([^\s]+)/.exec(output)
607
+ const pkg = asked?.[1]
583
608
  ?? /GET\s+\S*\/([^/\s]+):/.exec(output)?.[1].replace(/%2[Ff]/g, '/')
609
+ const requested = asked?.[2]
584
610
  const zh = pkg === undefined ? '' : `(${pkg})`
585
611
  const en = pkg === undefined ? '' : ` (${pkg})`
586
612
  const hostPeer = pkg !== undefined && HOST_NAMESPACE_RE.test(pkg)
613
+ if (hostPeer) {
614
+ return {
615
+ code: 'no-matching-version',
616
+ recoverable: false,
617
+ pkg,
618
+ message: `插件声明依赖的宿主包${zh}在 registry 上没有满足其版本范围的发布版(宿主运行时会自带它,npm 上不会出现这个版本)。市场会自动重试一次,放行这条 peer 依赖 / a plugin's declared host peer${en} has no published version satisfying its range — the runtime provides it, so npm carries no such version. The market retries once with that peer exempted from auto-install`,
619
+ }
620
+ }
621
+ const exact = requested !== undefined
622
+ && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test(requested)
623
+ if (exact) {
624
+ const pin = pkg === undefined ? requested : `${pkg}@${requested}`
625
+ const latest = pkg === undefined
626
+ ? undefined
627
+ : new RegExp(
628
+ `The latest release of\\s+${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s+is\\s+"([^"]+)"`,
629
+ 'i',
630
+ ).exec(output)?.[1]
631
+ const latestZh = latest === undefined ? '' : `(它目前看到的最新是 ${latest})`
632
+ const latestEn = latest === undefined ? '' : ` (it currently reports latest as ${latest})`
633
+ return {
634
+ code: 'exact-version-missing',
635
+ recoverable: false,
636
+ ...(pkg === undefined ? {} : { pkg }),
637
+ message: `当前下载线路暂时找不到精确版本 ${pin}${latestZh}。常见原因是线路还没同步到刚发布的版本,不等于这个版本不存在。已自动重试一次仍失败——请稍后再试;若下载区域是中国大陆,也可改成「全球」后再试 / this download route cannot see the exact version ${pin} yet${latestEn}. Often the route has not synced a just-published release — that does not mean the version is missing from npm. One automatic retry failed too — try again shortly; if Download region is China mainland, you can also switch it to Global and retry`,
638
+ }
639
+ }
587
640
  return {
588
641
  code: 'no-matching-version',
589
642
  recoverable: false,
590
- pkg,
591
- message: hostPeer
592
- ? `插件声明依赖的宿主包${zh}在 registry 上没有满足其版本范围的发布版(宿主运行时会自带它,npm 上不会出现这个版本)。市场会自动重试一次,放行这条 peer 依赖 / a plugin's declared host peer${en} has no published version satisfying its range — the runtime provides it, so npm carries no such version. The market retries once with that peer exempted from auto-install`
593
- : `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
643
+ ...(pkg === undefined ? {} : { pkg }),
644
+ message: `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
594
645
  }
595
646
  }
596
647
  // #808 by @Tofu707: the profile's own pnpm-lock.yaml is what is wrong, in
@@ -746,6 +797,16 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
746
797
  // a plain retry to succeed seconds later. withHoistRecovery retries once;
747
798
  // this message covers the case where the retry lost too.
748
799
  if (isTransientPnpmFailure(output)) {
800
+ // Keep code `transient-network` so withHoistRecovery still does the #83
801
+ // same-argv retry; only the final message changes when that retry lost
802
+ // against the China mirror's tarball host (#926).
803
+ if (isChinaMirrorTarballReset(output)) {
804
+ return {
805
+ code: 'transient-network',
806
+ recoverable: false,
807
+ message: '中国大陆下载线路在拉这个安装包时被断开了。再走同一条线路通常没用。打开设置,把「下载区域」改成「全球」,然后再装一次 / the China mainland download route was interrupted while fetching this package. Trying the same route again usually will not help. Open Settings, switch Download region to Global, and install again',
808
+ }
809
+ }
749
810
  return {
750
811
  code: 'transient-network',
751
812
  recoverable: false,
package/src/profile.ts CHANGED
@@ -5,9 +5,11 @@
5
5
  */
6
6
 
7
7
  import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
8
9
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
9
10
  import { isDeepStrictEqual } from 'node:util'
10
11
  import { JSON_SCHEMA, load } from 'js-yaml'
12
+ import { findDshInstallDir } from './dsh-install.ts'
11
13
  import { resolveDshHome } from './home-paths.ts'
12
14
  import { githubRemoteIdentities, githubRepoIdentities, hostedRepoKey, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.ts'
13
15
 
@@ -1340,7 +1342,7 @@ export function conflictingEntryIds(
1340
1342
  * @param profileDirectory - resolved profile directory (host-authoritative under Desktop).
1341
1343
  * @param name - installed package name.
1342
1344
  */
1343
- export function hasLoadableEntry(profileDirectory: string, name: string): boolean {
1345
+ export function hasLoadableEntry(profileDirectory: string, name: string, hostDirectory?: string | null): boolean {
1344
1346
  const dir = join(profileDirectory, 'node_modules', name)
1345
1347
  if (entryArtifactExists(dir)) return true
1346
1348
  // A carrier is only sound when something it mounts is itself loadable.
@@ -1353,11 +1355,39 @@ export function hasLoadableEntry(profileDirectory: string, name: string): boolea
1353
1355
  // exiting 0 was immediately followed by the market removing what it had
1354
1356
  // just, correctly, installed.
1355
1357
  const workspaceRoot = dirname(profileDirectory)
1356
- return bundlePatchTargets(dir)
1357
- .filter(target => target !== name)
1358
- .some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
1359
- || entryArtifactExists(join(dir, 'node_modules', target))
1360
- || entryArtifactExists(join(workspaceRoot, 'node_modules', target)))
1358
+ const targets = bundlePatchTargets(dir).filter(target => target !== name)
1359
+ if (targets.some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
1360
+ || entryArtifactExists(join(dir, 'node_modules', target))
1361
+ || entryArtifactExists(join(workspaceRoot, 'node_modules', target)))) return true
1362
+ // #906: the host's OWN packages live in its install tree, not in any
1363
+ // profile node_modules — `<install>/dsh/node_modules/...`, often inside
1364
+ // app.asar. A carrier whose patch mounts nothing but in-box packages
1365
+ // (@deepseek-ai/dsh-mcp-client and the like) therefore failed every root
1366
+ // above, was reported "broken", and — through validateAddedPlugins — was
1367
+ // uninstalled right after a successful install. `undefined` resolves the
1368
+ // install dir lazily (only carriers pay for it); `null` means "do not look".
1369
+ //
1370
+ // Inside app.asar the probe is blind for EVERY @deepseek-ai/ name (the
1371
+ // same measurement behind #676), so there identity decides instead of the
1372
+ // filesystem — the host publishes that scope, and our own composer loads
1373
+ // those packages from the archive at boot.
1374
+ const host = hostDirectory === undefined ? findDshInstallDir() : hostDirectory
1375
+ if (host === null || targets.length === 0) return false
1376
+ const packaged = /[/\\]app\.asar[/\\]/iu.test(host)
1377
+ return targets.some(target => packaged && target.startsWith('@deepseek-ai/')
1378
+ || packageResolvableFrom(join(host, 'package.json'), target))
1379
+ }
1380
+
1381
+ /** Whether `target` resolves from the host's own dependency tree. */
1382
+ function packageResolvableFrom(anchorPackageJson: string, target: string): boolean {
1383
+ try {
1384
+ const paths = createRequire(anchorPackageJson).resolve.paths(target) ?? []
1385
+ return paths.some(searchPath => existsSync(join(searchPath, target, 'package.json')))
1386
+ } catch {
1387
+ // An unreadable anchor says nothing about the target: treat as absent
1388
+ // rather than loadable, so the caller keeps its conservative answer.
1389
+ return false
1390
+ }
1361
1391
  }
1362
1392
 
1363
1393
  /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
package/src/recovery.ts CHANGED
@@ -33,10 +33,10 @@ import { spawn } from 'node:child_process'
33
33
  import { appendFileSync, openSync, readFileSync } from 'node:fs'
34
34
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
35
35
  import { connect } from 'node:net'
36
- import { homedir } from 'node:os'
37
36
  import { resolve } from 'node:path'
38
37
  import { fileURLToPath } from 'node:url'
39
38
  import { readJsonBody, sameOrigin, sendJson } from './http.ts'
39
+ import { redactHomePaths } from './log-redaction.ts'
40
40
  import { disableRow, enableRow } from './patch.ts'
41
41
  import { addProfileBundle, removeProfileBundle } from './profile.ts'
42
42
 
@@ -566,8 +566,7 @@ async function waitForPortFree(port: number, timeoutMs: number): Promise<boolean
566
566
  * one unreadable line, which is the opposite of what the export is for.
567
567
  */
568
568
  function redactLog(text: string): string {
569
- return text
570
- .replaceAll(homedir(), '~')
569
+ return redactHomePaths(text)
571
570
  .replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-***')
572
571
  .replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, 'gh*_***')
573
572
  .replace(/npm_[A-Za-z0-9]{16,}/g, 'npm_***')
package/src/regions.ts CHANGED
@@ -38,7 +38,14 @@ export function asRegion(value: unknown): Region | null {
38
38
  * pnpm's registry alone and setting it.
39
39
  */
40
40
  export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
41
- const NPM_CHINA = 'https://mirrors.cloud.tencent.com/npm'
41
+ /**
42
+ * Default npm registry for the China download region, no trailing slash.
43
+ *
44
+ * Exported so failure classifiers can recognize this host in pnpm's output
45
+ * (#926) without duplicating the URL.
46
+ */
47
+ export const CHINA_NPM_REGISTRY = 'https://mirrors.cloud.tencent.com/npm'
48
+ const NPM_CHINA = CHINA_NPM_REGISTRY
42
49
 
43
50
  /**
44
51
  * First public prefix used for unauthenticated GitHub reads, no trailing slash.
package/src/routes.ts CHANGED
@@ -5167,6 +5167,24 @@ sendJson(response, 200, { updates })
5167
5167
  logEvent('warn', 'update-bundles-dropped',
5168
5168
  `${name}: after this update ${droppedBundles.join(', ')} ${droppedBundles.length === 1 ? 'is' : 'are'} still declared in dependencies but no longer in dsh.profile.bundles, so ${droppedBundles.length === 1 ? 'it' : 'they'} will not load — the market did not write that change (#720)`)
5169
5169
  }
5170
+ // #935: the desktop manager's removeBundle writes the bundle row
5171
+ // out FIRST and only then checks whether a live fiber still uses
5172
+ // the package — so a `bundle-in-use` refusal leaves the row gone
5173
+ // while dependencies and node_modules stay. The plugin keeps
5174
+ // running this session and silently fails to load next boot.
5175
+ // Restore the row, scoped to exactly that refusal and to this
5176
+ // operation's own target: the read-only stance in #720/#696
5177
+ // still holds for every other way a row can disappear.
5178
+ if (!ok && !cancelled && result.refusalCode === 'bundle-in-use') {
5179
+ const targetRowDropped = bundlesDroppedFromProfile(
5180
+ manifestBefore, config.profile, activeProfileDir,
5181
+ new Set(removedDeclaration === null ? [] : [removedDeclaration.name]),
5182
+ ).includes(name)
5183
+ if (targetRowDropped && addProfileBundle(activeProfileDir, name)) {
5184
+ logEvent('warn', 'update-bundles-restored',
5185
+ `${name}: the desktop app removed its bundle row before refusing the update (bundle-in-use) — restored it so the plugin still loads on the next boot (#935)`)
5186
+ }
5187
+ }
5170
5188
  logEvent(ok || cancelled ? 'info' : 'error', 'update',
5171
5189
  `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ` STALE(${staleReason ?? 'unknown'})` : ''}${ok || cancelled ? '' : ` err=${failureDetail(result)}`}`)
5172
5190
  // A user-cancelled run is a quiet outcome, not an error.
@@ -6355,7 +6373,13 @@ sendJson(response, 200, { updates })
6355
6373
  // (a 404 for this package's own download: the classifier names
6356
6374
  // the last path segment, the tarball file for that URL form).
6357
6375
  const ownTarball = `${plainTarget.slice(plainTarget.lastIndexOf('/') + 1)}-${String(registryLatest)}.tgz`
6358
- const notOnMirror = (failure?.code === 'no-matching-version' && aboutThisPackage)
6376
+ // `exact-version-missing` (#927) is the same lag shape with a
6377
+ // clearer sentence; install still falls back to the bare name
6378
+ // after withHoistRecovery's one same-argv retry loses.
6379
+ const notOnMirror = (
6380
+ (failure?.code === 'no-matching-version' || failure?.code === 'exact-version-missing')
6381
+ && aboutThisPackage
6382
+ )
6359
6383
  || (failure?.code === 'fetch-404' && (failure.pkg === plainTarget || failure.pkg === ownTarball))
6360
6384
  if (heldBack || notOnMirror) {
6361
6385
  // The user asked for the young release anyway. That request is
package/src/updates.ts CHANGED
@@ -363,7 +363,20 @@ async function tagVersion(name: string, tag: string): Promise<string | null> {
363
363
  export async function fetchNpmLatest(name: string): Promise<string | null> {
364
364
  try {
365
365
  const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`))) as { version?: string }
366
- return typeof meta.version === 'string' ? meta.version : null
366
+ if (typeof meta.version === 'string') return meta.version
367
+ } catch {
368
+ // Fall through to the full packument (#932).
369
+ }
370
+ // #932: the `/latest` shortcut can fail on its own — measured HTTP 429 from
371
+ // registry.npmjs.org while the full packument answered 200 with the same
372
+ // dist-tag. Returning null there made a fresh install fall back to the bare
373
+ // name, and pnpm's fresh-release hold then installed an OLDER version than
374
+ // the catalog and the confirmation screen had just shown. The packument is
375
+ // a bigger read, so it is only asked when the shortcut failed.
376
+ try {
377
+ const doc = (await fetchJson(npmUrl(encodeURIComponent(name)))) as { 'dist-tags'?: { latest?: unknown } }
378
+ const latest = doc['dist-tags']?.latest
379
+ return typeof latest === 'string' ? latest : null
367
380
  } catch {
368
381
  return null
369
382
  }