dshmarket 1.66.42 → 1.66.44

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
@@ -1993,8 +1993,9 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
1993
1993
  }
1994
1994
  /** Heuristic: plugins that target a terminal surface rather than the web UI. */
1995
1995
  function looksTerminal(plugin, lang) {
1996
+ if (/\b(tui|cli|tty|terminal)\b|终端|命令行/i.test(plugin.name)) return true;
1996
1997
  const positiveDesc = (plugin.description && (plugin.description[lang] || plugin.description.en) || "").replace(/\b(?:no|without)\b[^.!?;:,。!?;\n]{0,80}\b(?:tui|cli|tty|terminal)\b/gi, "").replace(/(?:无需|无须|不需要|不用)[^。!?;\n]{0,48}(?:tui|cli|tty|terminal|终端|命令行)/gi, "");
1997
- return /\b(tui|cli|tty|terminal)\b|终端|命令行/i.test(plugin.name + " " + positiveDesc);
1998
+ return /\b(?:terminal|cli)[ -]only\b|\btui (?:app|plugin|client)\b|\bterminal (?:ui|app|plugin|client)\b|\bcommand[- ]line (?:tool|interface|app|client|plugin)\b|命令行(?:工具|插件|应用|界面|客户端)|终端(?:工具|插件|应用|界面|客户端)|只在终端|仅在终端|需要(?:一个)?终端|在终端(?:里|中)运行/i.test(positiveDesc);
1998
1999
  }
1999
2000
  /** Days per TimeRange (`all` has no cutoff and is handled by the caller). */
2000
2001
  const TIME_RANGE_DAYS = {
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)) {
@@ -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/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
@@ -1855,6 +1855,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1855
1855
  version,
1856
1856
  requirement: verdict.requirement,
1857
1857
  hostVersion: host?.version ?? null,
1858
+ // #923: the client's "find a compatible version" search is keyed by
1859
+ // the npm name, and the install branch could fall back to the catalog
1860
+ // entry's `npm` while the update branch had nothing. The value is
1861
+ // already here — it is this function's first argument and the key the
1862
+ // registry lookup above used — so publish it.
1863
+ npmName,
1858
1864
  },
1859
1865
  error: nothingWasInstalled
1860
1866
  ? `${displayName} ${version ?? ''} 要求的 DSH 版本是 ${verdict.requirement ?? '未知'},而当前运行的是 ${host?.version ?? '未知版本'},装上多半会直接报错。已停止,没有安装任何东西。 / ${displayName} ${version ?? ''} declares it needs DSH ${verdict.requirement ?? '(unknown)'}, and this host is ${host?.version ?? 'unknown'}; installing it would most likely break the plugin. Nothing was installed.`
@@ -4478,6 +4484,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4478
4484
  if (pinnedForIdentification) {
4479
4485
  declareProfileDependency(config.profile, name, pinValue, activeProfileDir);
4480
4486
  }
4487
+ // #928: an update is remove-then-add, and a host killed between
4488
+ // the two leaves a package that is simply gone — with no record
4489
+ // of what was being attempted, because the completion log never
4490
+ // runs. Write the intent first, so the log can answer "where did
4491
+ // my plugin go" after the fact.
4492
+ logEvent('info', 'update-start', `${name} -> ${target} (profile ${config.profile})`);
4481
4493
  const result = await runPlugin(config.profile, addArgs);
4482
4494
  const cancelled = result.cancelled;
4483
4495
  // The pin above is the market's own write, so it is undone whenever
@@ -4939,6 +4951,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4939
4951
  if (droppedBundles.length > 0) {
4940
4952
  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
4953
  }
4954
+ // #935: the desktop manager's removeBundle writes the bundle row
4955
+ // out FIRST and only then checks whether a live fiber still uses
4956
+ // the package — so a `bundle-in-use` refusal leaves the row gone
4957
+ // while dependencies and node_modules stay. The plugin keeps
4958
+ // running this session and silently fails to load next boot.
4959
+ // Restore the row, scoped to exactly that refusal and to this
4960
+ // operation's own target: the read-only stance in #720/#696
4961
+ // still holds for every other way a row can disappear.
4962
+ if (!ok && !cancelled && result.refusalCode === 'bundle-in-use') {
4963
+ const targetRowDropped = bundlesDroppedFromProfile(manifestBefore, config.profile, activeProfileDir, new Set(removedDeclaration === null ? [] : [removedDeclaration.name])).includes(name);
4964
+ if (targetRowDropped && addProfileBundle(activeProfileDir, name)) {
4965
+ 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)`);
4966
+ }
4967
+ }
4942
4968
  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
4969
  // A user-cancelled run is a quiet outcome, not an error.
4944
4970
  sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
@@ -5731,6 +5757,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5731
5757
  // it is meant to clean up after, and the rows outlive the package
5732
5758
  // as boot-time orphans (#799).
5733
5759
  const ownedRows = rowIdsForPackage(host, activeProfileDir, name);
5760
+ // #928: the same intent record as the update path — an uninstall
5761
+ // interrupted mid-remove leaves the same "where did it go" with no
5762
+ // entry saying what was running.
5763
+ logEvent('info', 'uninstall-start', `${name} (profile ${config.profile})`);
5734
5764
  const result = await runPlugin(config.profile, ['remove', name]);
5735
5765
  const cancelled = result.cancelled;
5736
5766
  const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
@@ -6128,7 +6158,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
6128
6158
  // (a 404 for this package's own download: the classifier names
6129
6159
  // the last path segment, the tarball file for that URL form).
6130
6160
  const ownTarball = `${plainTarget.slice(plainTarget.lastIndexOf('/') + 1)}-${String(registryLatest)}.tgz`;
6131
- const notOnMirror = (failure?.code === 'no-matching-version' && aboutThisPackage)
6161
+ // `exact-version-missing` (#927) is the same lag shape with a
6162
+ // clearer sentence; install still falls back to the bare name
6163
+ // after withHoistRecovery's one same-argv retry loses.
6164
+ const notOnMirror = ((failure?.code === 'no-matching-version' || failure?.code === 'exact-version-missing')
6165
+ && aboutThisPackage)
6132
6166
  || (failure?.code === 'fetch-404' && (failure.pkg === plainTarget || failure.pkg === ownTarball));
6133
6167
  if (heldBack || notOnMirror) {
6134
6168
  // The user asked for the young release anyway. That request is
@@ -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. */
@@ -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.42",
4
+ "version": "1.66.44",
5
5
  "icon": "assets/logo.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -430,15 +430,21 @@ export function readSession(key: string): any {
430
430
 
431
431
  /** Heuristic: plugins that target a terminal surface rather than the web UI. */
432
432
  export function looksTerminal(plugin: RegistryPlugin, lang: string): boolean {
433
+ // The package NAME is a claim about the package itself: `dsh-tui` is a
434
+ // terminal surface. Descriptions are not — they mention the terminal for
435
+ // many reasons, including a companion CLI that ships from the same
436
+ // repository (#931). A bare "CLI"/"terminal" mention in prose used to be
437
+ // enough to warn that a web plugin "may not work in the web app", and a
438
+ // warning that fires on ordinary plugins is one users learn to ignore.
439
+ if (/\b(tui|cli|tty|terminal)\b|终端|命令行/i.test(plugin.name)) return true
433
440
  const desc = (plugin.description && (plugin.description[lang] || plugin.description.en)) || ''
434
- // A description can mention a CLI only to say it is NOT required. Treating
435
- // that as positive evidence labels web plugins as terminal-only. Strip
436
- // bounded negated clauses before applying the deliberately broad heuristic;
437
- // the package name remains untouched and therefore stays strong evidence.
441
+ // A description can also mention a CLI only to say it is NOT required
442
+ // (#237). Strip bounded negated clauses first.
438
443
  const positiveDesc = desc
439
444
  .replace(/\b(?:no|without)\b[^.!?;:,。!?;\n]{0,80}\b(?:tui|cli|tty|terminal)\b/gi, '')
440
445
  .replace(/(?:无需|无须|不需要|不用)[^。!?;\n]{0,48}(?:tui|cli|tty|terminal|终端|命令行)/gi, '')
441
- return /\b(tui|cli|tty|terminal)\b|终端|命令行/i.test(plugin.name + ' ' + positiveDesc)
446
+ // Only phrases that assert THIS plugin's own surface count.
447
+ return /\b(?:terminal|cli)[ -]only\b|\btui (?:app|plugin|client)\b|\bterminal (?:ui|app|plugin|client)\b|\bcommand[- ]line (?:tool|interface|app|client|plugin)\b|命令行(?:工具|插件|应用|界面|客户端)|终端(?:工具|插件|应用|界面|客户端)|只在终端|仅在终端|需要(?:一个)?终端|在终端(?:里|中)运行/i.test(positiveDesc)
442
448
  }
443
449
 
444
450
  /** Sortable field for the Discover list. */
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')
@@ -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/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
@@ -2050,6 +2050,12 @@ export function mountMarketRoutes(
2050
2050
  version,
2051
2051
  requirement: verdict.requirement,
2052
2052
  hostVersion: host?.version ?? null,
2053
+ // #923: the client's "find a compatible version" search is keyed by
2054
+ // the npm name, and the install branch could fall back to the catalog
2055
+ // entry's `npm` while the update branch had nothing. The value is
2056
+ // already here — it is this function's first argument and the key the
2057
+ // registry lookup above used — so publish it.
2058
+ npmName,
2053
2059
  },
2054
2060
  error: nothingWasInstalled
2055
2061
  ? `${displayName} ${version ?? ''} 要求的 DSH 版本是 ${verdict.requirement ?? '未知'},而当前运行的是 ${host?.version ?? '未知版本'},装上多半会直接报错。已停止,没有安装任何东西。 / ${displayName} ${version ?? ''} declares it needs DSH ${verdict.requirement ?? '(unknown)'}, and this host is ${host?.version ?? 'unknown'}; installing it would most likely break the plugin. Nothing was installed.`
@@ -4688,6 +4694,12 @@ sendJson(response, 200, { updates })
4688
4694
  if (pinnedForIdentification) {
4689
4695
  declareProfileDependency(config.profile, name, pinValue, activeProfileDir)
4690
4696
  }
4697
+ // #928: an update is remove-then-add, and a host killed between
4698
+ // the two leaves a package that is simply gone — with no record
4699
+ // of what was being attempted, because the completion log never
4700
+ // runs. Write the intent first, so the log can answer "where did
4701
+ // my plugin go" after the fact.
4702
+ logEvent('info', 'update-start', `${name} -> ${target} (profile ${config.profile})`)
4691
4703
  const result = await runPlugin(config.profile, addArgs)
4692
4704
  const cancelled = result.cancelled
4693
4705
  // The pin above is the market's own write, so it is undone whenever
@@ -5167,6 +5179,24 @@ sendJson(response, 200, { updates })
5167
5179
  logEvent('warn', 'update-bundles-dropped',
5168
5180
  `${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
5181
  }
5182
+ // #935: the desktop manager's removeBundle writes the bundle row
5183
+ // out FIRST and only then checks whether a live fiber still uses
5184
+ // the package — so a `bundle-in-use` refusal leaves the row gone
5185
+ // while dependencies and node_modules stay. The plugin keeps
5186
+ // running this session and silently fails to load next boot.
5187
+ // Restore the row, scoped to exactly that refusal and to this
5188
+ // operation's own target: the read-only stance in #720/#696
5189
+ // still holds for every other way a row can disappear.
5190
+ if (!ok && !cancelled && result.refusalCode === 'bundle-in-use') {
5191
+ const targetRowDropped = bundlesDroppedFromProfile(
5192
+ manifestBefore, config.profile, activeProfileDir,
5193
+ new Set(removedDeclaration === null ? [] : [removedDeclaration.name]),
5194
+ ).includes(name)
5195
+ if (targetRowDropped && addProfileBundle(activeProfileDir, name)) {
5196
+ logEvent('warn', 'update-bundles-restored',
5197
+ `${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)`)
5198
+ }
5199
+ }
5170
5200
  logEvent(ok || cancelled ? 'info' : 'error', 'update',
5171
5201
  `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ` STALE(${staleReason ?? 'unknown'})` : ''}${ok || cancelled ? '' : ` err=${failureDetail(result)}`}`)
5172
5202
  // A user-cancelled run is a quiet outcome, not an error.
@@ -5961,6 +5991,10 @@ sendJson(response, 200, { updates })
5961
5991
  // it is meant to clean up after, and the rows outlive the package
5962
5992
  // as boot-time orphans (#799).
5963
5993
  const ownedRows = rowIdsForPackage(host, activeProfileDir, name)
5994
+ // #928: the same intent record as the update path — an uninstall
5995
+ // interrupted mid-remove leaves the same "where did it go" with no
5996
+ // entry saying what was running.
5997
+ logEvent('info', 'uninstall-start', `${name} (profile ${config.profile})`)
5964
5998
  const result = await runPlugin(config.profile, ['remove', name])
5965
5999
  const cancelled = result.cancelled
5966
6000
  const ok = result.exitCode === 0 && !result.timedOut && !cancelled
@@ -6355,7 +6389,13 @@ sendJson(response, 200, { updates })
6355
6389
  // (a 404 for this package's own download: the classifier names
6356
6390
  // the last path segment, the tarball file for that URL form).
6357
6391
  const ownTarball = `${plainTarget.slice(plainTarget.lastIndexOf('/') + 1)}-${String(registryLatest)}.tgz`
6358
- const notOnMirror = (failure?.code === 'no-matching-version' && aboutThisPackage)
6392
+ // `exact-version-missing` (#927) is the same lag shape with a
6393
+ // clearer sentence; install still falls back to the bare name
6394
+ // after withHoistRecovery's one same-argv retry loses.
6395
+ const notOnMirror = (
6396
+ (failure?.code === 'no-matching-version' || failure?.code === 'exact-version-missing')
6397
+ && aboutThisPackage
6398
+ )
6359
6399
  || (failure?.code === 'fetch-404' && (failure.pkg === plainTarget || failure.pkg === ownTarball))
6360
6400
  if (heldBack || notOnMirror) {
6361
6401
  // 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
  }