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 +2 -1
- package/lib/install.js +9 -0
- package/lib/log-redaction.js +24 -0
- package/lib/log.js +2 -3
- package/lib/patch.js +53 -25
- package/lib/pnpm-compat.js +60 -5
- package/lib/recovery.js +2 -3
- package/lib/regions.js +8 -1
- package/lib/routes.js +35 -1
- package/lib/types/log-redaction.d.ts +2 -0
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/lib/types/regions.d.ts +7 -0
- package/lib/updates.js +16 -1
- package/package.json +1 -1
- package/src/client/market-data.ts +11 -5
- package/src/install.ts +10 -0
- package/src/log-redaction.ts +24 -0
- package/src/log.ts +2 -3
- package/src/patch.ts +48 -24
- package/src/pnpm-compat.ts +68 -7
- package/src/recovery.ts +2 -3
- package/src/regions.ts +8 -1
- package/src/routes.ts +41 -1
- package/src/updates.ts +14 -1
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(
|
|
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 {
|
|
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_.-]+)\
|
|
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
|
|
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(
|
|
165
|
+
disables.push(id);
|
|
155
166
|
else if (/^ {2}disabled: false\s*$/u.test(next))
|
|
156
|
-
forced.push(
|
|
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
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
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
|
|
448
|
+
function rewriteRowBlocks(text, rowId, disabled, replace) {
|
|
439
449
|
const id = escapeRegExp(rowId);
|
|
440
|
-
const
|
|
441
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
|
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
|
|
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));
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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. */
|
package/lib/types/regions.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
435
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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_.-]+)\
|
|
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
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
*
|
|
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
|
|
455
|
+
function rewriteRowBlocks(text: string, rowId: string, disabled: 'true' | 'false' | 'any', replace: (block: string) => string): string {
|
|
446
456
|
const id = escapeRegExp(rowId)
|
|
447
|
-
const
|
|
448
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
|
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
|
|
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(', ')}`)
|
package/src/pnpm-compat.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|