dshmarket 1.66.41 → 1.66.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/changelog.js +33 -3
- package/lib/install.js +13 -1
- 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/profile.js +37 -5
- package/lib/recovery.js +2 -3
- package/lib/regions.js +8 -1
- package/lib/routes.js +19 -1
- package/lib/types/changelog.d.ts +9 -0
- package/lib/types/log-redaction.d.ts +2 -0
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/lib/types/profile.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/changelog.ts +33 -3
- package/src/install.ts +14 -1
- 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/profile.ts +36 -6
- package/src/recovery.ts +2 -3
- package/src/regions.ts +8 -1
- package/src/routes.ts +25 -1
- package/src/updates.ts +14 -1
package/lib/changelog.js
CHANGED
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
import { fileFromTarball } from './catalog-npm.js';
|
|
24
24
|
import { marketFetch } from './net.js';
|
|
25
25
|
import { activeRegion, routesFor } from './regions.js';
|
|
26
|
-
import { profileDir, readGitResolutionCommit, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.js';
|
|
26
|
+
import { profileDir, readGitResolutionCommit, readInstalled, readInstalledRepoEvidence, readInstalledVersion, readLockCommits } from './profile.js';
|
|
27
27
|
import { githubRepoOfRemote, hostedRepoKey, isGitHostedSpec, lookupRepoFromUrl, parseHostShorthand, parseSourceUrl, repoOf, repoOfTarget } from './sources.js';
|
|
28
|
-
import { checkUpdates } from './updates.js';
|
|
28
|
+
import { checkUpdates, compareVersions } from './updates.js';
|
|
29
29
|
import { loadRegistry } from './registry.js';
|
|
30
30
|
const UPDATES_PACKAGE = 'dsh-plugin-updates';
|
|
31
31
|
const UPDATES_FILE = 'package/updates.json';
|
|
@@ -118,6 +118,20 @@ export function sliceCommitsAt(items, installed) {
|
|
|
118
118
|
}
|
|
119
119
|
return { items: out, found: false };
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The version a release tag names, when it names one.
|
|
123
|
+
*
|
|
124
|
+
* Tags come as `v1.2.3`, `1.2.3`, `@scope/pkg@1.2.3` or `pkg-v1.2.3`; the
|
|
125
|
+
* FIRST semver-shaped run is the version in all of them. A tag that names no
|
|
126
|
+
* version (`nightly`) returns null, and callers must treat "unknown" as "do
|
|
127
|
+
* not judge" rather than "older".
|
|
128
|
+
*/
|
|
129
|
+
export function releaseVersionOf(tag) {
|
|
130
|
+
if (tag === null)
|
|
131
|
+
return null;
|
|
132
|
+
const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(tag);
|
|
133
|
+
return match?.[1] ?? null;
|
|
134
|
+
}
|
|
121
135
|
/** The repo url key an entry's update notes live under, or null. */
|
|
122
136
|
export function repoKeyOf(spec) {
|
|
123
137
|
const repo = githubIdentityOf(spec)?.split('#')[0] ?? null;
|
|
@@ -407,7 +421,23 @@ export async function updateNotesFor(profile, explicitDir, name) {
|
|
|
407
421
|
current = statuses?.[name]?.current ?? null;
|
|
408
422
|
}
|
|
409
423
|
if (entry?.release !== undefined && entry.release !== null && (entry.release.body !== '' || entry.release.tag !== null)) {
|
|
410
|
-
|
|
424
|
+
// The release feed is a DAILY snapshot while the update target is read
|
|
425
|
+
// live from npm, so the snapshot's newest release is often the version
|
|
426
|
+
// already installed (or older). Showing it next to "1.66.39 → 1.66.40"
|
|
427
|
+
// describes a change the user already has (#925). When the release
|
|
428
|
+
// names a version no newer than the installed one AND a better tier
|
|
429
|
+
// exists, take the better tier: the commit tail is sliced at this
|
|
430
|
+
// install's sha, and npm publish times are newer than the install by
|
|
431
|
+
// construction. With nothing better, the stale release still beats an
|
|
432
|
+
// empty dialog.
|
|
433
|
+
const installed = readInstalledVersion(profile, name, activeProfileDir);
|
|
434
|
+
const released = releaseVersionOf(entry.release.tag);
|
|
435
|
+
const behind = installed !== null && released !== null
|
|
436
|
+
&& (compareVersions(released, installed) ?? 0) <= 0;
|
|
437
|
+
const hasCommits = (entry.commits?.length ?? 0) > 0;
|
|
438
|
+
if (!(behind && (hasCommits || (specKey === null && npmName !== null)))) {
|
|
439
|
+
return { kind: 'release', release: entry.release };
|
|
440
|
+
}
|
|
411
441
|
}
|
|
412
442
|
if (entry?.commits !== undefined && entry.commits.length > 0) {
|
|
413
443
|
return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) };
|
package/lib/install.js
CHANGED
|
@@ -248,6 +248,15 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
248
248
|
logEvent('warn', 'install', `transient network failure while pnpm replayed the dependency tree (#83) — retrying once`);
|
|
249
249
|
result = await run(profile, pluginArgs);
|
|
250
250
|
}
|
|
251
|
+
else if (failure?.code === 'exact-version-missing'
|
|
252
|
+
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove' || pluginArgs[0] === 'update')) {
|
|
253
|
+
// #927: market pinned name@x.y.z from /latest, but this registry's
|
|
254
|
+
// packument (often a mirror) does not list that version yet. Same argv
|
|
255
|
+
// once more gives pnpm a chance to re-fetch; if it still fails the
|
|
256
|
+
// classifier's message names "try later / switch Download region".
|
|
257
|
+
logEvent('warn', 'install', `exact version missing on this registry's packument (#927) — retrying once to re-fetch`);
|
|
258
|
+
result = await run(profile, pluginArgs);
|
|
259
|
+
}
|
|
251
260
|
else if (failure?.code === 'fetch-timeout'
|
|
252
261
|
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
|
|
253
262
|
&& !pluginArgs.includes(FETCH_TIMEOUT_OVERRIDE)) {
|
|
@@ -662,7 +671,10 @@ export async function validateAddedPlugins(run, profile, before, explicitDir, ho
|
|
|
662
671
|
const packageDir = join(dir, 'node_modules', n);
|
|
663
672
|
// hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
|
|
664
673
|
// ship no entry of their own (#103) and must not be uninstalled here.
|
|
665
|
-
|
|
674
|
+
// The host install tree is passed too (#906): a carrier mounting only
|
|
675
|
+
// in-box packages resolves nowhere under the profile, and judging it by
|
|
676
|
+
// that alone deleted a package the host had just installed correctly.
|
|
677
|
+
if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n, hostDirectory ?? findDshInstallDir())) {
|
|
666
678
|
removedBroken.push(n);
|
|
667
679
|
await removeAndReconcile(run, profile, dir, n, hostDirectory);
|
|
668
680
|
continue;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
/** Mask only this user's home prefix, leaving the rest of each path intact. */
|
|
3
|
+
export function redactHomePaths(text) {
|
|
4
|
+
const directory = homedir();
|
|
5
|
+
// Detect the home path's syntax rather than the host running the exporter.
|
|
6
|
+
// Windows is case-insensitive and accepts either separator, including mixed
|
|
7
|
+
// paths from pnpm. Repeated separators also cover JSON-escaped NDJSON history.
|
|
8
|
+
const windows = /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(directory);
|
|
9
|
+
const home = directory.replace(windows ? /[\\/]+$/ : /\/+$/, '');
|
|
10
|
+
// A POSIX root home must not turn every absolute path into a home path.
|
|
11
|
+
if (home === '')
|
|
12
|
+
return text;
|
|
13
|
+
const escape = (part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
14
|
+
const path = windows ? home.split(/[\\/]/).map(escape).join('[\\\\/]+') : escape(home);
|
|
15
|
+
// Do not match a home embedded in another path/text, or a sibling such as
|
|
16
|
+
// Alice-other or Alice.backup. File URLs still contain an absolute home;
|
|
17
|
+
// quotes/brackets allow bare homes in log prose.
|
|
18
|
+
const before = String.raw `(?:(?<![\p{L}\p{N}_./\\-])|(?<=\bfile://)|(?<=\bfile:///))`;
|
|
19
|
+
// Trimming C:\ leaves C:, but only an absolute path belongs to that root.
|
|
20
|
+
const after = windows && /^[a-z]:$/i.test(home)
|
|
21
|
+
? String.raw `(?=[\\/])`
|
|
22
|
+
: windows ? String.raw `(?=$|[\\/\s"'\x60<>()[\]{},;:!?])` : String.raw `(?=$|[/\s"'\x60<>()[\]{},;:!?])`;
|
|
23
|
+
return text.replace(new RegExp(before + path + after, windows ? 'giu' : 'gu'), '~');
|
|
24
|
+
}
|
package/lib/log.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { dirname } from 'node:path';
|
|
16
|
-
import {
|
|
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/profile.js
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* functions of the directory contents; no processes, no network.
|
|
5
5
|
*/
|
|
6
6
|
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
7
8
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
8
9
|
import { isDeepStrictEqual } from 'node:util';
|
|
9
10
|
import { JSON_SCHEMA, load } from 'js-yaml';
|
|
11
|
+
import { findDshInstallDir } from './dsh-install.js';
|
|
10
12
|
import { resolveDshHome } from './home-paths.js';
|
|
11
13
|
import { githubRemoteIdentities, githubRepoIdentities, hostedRepoKey, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.js';
|
|
12
14
|
/**
|
|
@@ -1296,7 +1298,7 @@ export function conflictingEntryIds(profileDirectory, candidate, installedBundle
|
|
|
1296
1298
|
* @param profileDirectory - resolved profile directory (host-authoritative under Desktop).
|
|
1297
1299
|
* @param name - installed package name.
|
|
1298
1300
|
*/
|
|
1299
|
-
export function hasLoadableEntry(profileDirectory, name) {
|
|
1301
|
+
export function hasLoadableEntry(profileDirectory, name, hostDirectory) {
|
|
1300
1302
|
const dir = join(profileDirectory, 'node_modules', name);
|
|
1301
1303
|
if (entryArtifactExists(dir))
|
|
1302
1304
|
return true;
|
|
@@ -1310,11 +1312,41 @@ export function hasLoadableEntry(profileDirectory, name) {
|
|
|
1310
1312
|
// exiting 0 was immediately followed by the market removing what it had
|
|
1311
1313
|
// just, correctly, installed.
|
|
1312
1314
|
const workspaceRoot = dirname(profileDirectory);
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
.some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
|
|
1315
|
+
const targets = bundlePatchTargets(dir).filter(target => target !== name);
|
|
1316
|
+
if (targets.some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
|
|
1316
1317
|
|| entryArtifactExists(join(dir, 'node_modules', target))
|
|
1317
|
-
|| entryArtifactExists(join(workspaceRoot, 'node_modules', target)))
|
|
1318
|
+
|| entryArtifactExists(join(workspaceRoot, 'node_modules', target))))
|
|
1319
|
+
return true;
|
|
1320
|
+
// #906: the host's OWN packages live in its install tree, not in any
|
|
1321
|
+
// profile node_modules — `<install>/dsh/node_modules/...`, often inside
|
|
1322
|
+
// app.asar. A carrier whose patch mounts nothing but in-box packages
|
|
1323
|
+
// (@deepseek-ai/dsh-mcp-client and the like) therefore failed every root
|
|
1324
|
+
// above, was reported "broken", and — through validateAddedPlugins — was
|
|
1325
|
+
// uninstalled right after a successful install. `undefined` resolves the
|
|
1326
|
+
// install dir lazily (only carriers pay for it); `null` means "do not look".
|
|
1327
|
+
//
|
|
1328
|
+
// Inside app.asar the probe is blind for EVERY @deepseek-ai/ name (the
|
|
1329
|
+
// same measurement behind #676), so there identity decides instead of the
|
|
1330
|
+
// filesystem — the host publishes that scope, and our own composer loads
|
|
1331
|
+
// those packages from the archive at boot.
|
|
1332
|
+
const host = hostDirectory === undefined ? findDshInstallDir() : hostDirectory;
|
|
1333
|
+
if (host === null || targets.length === 0)
|
|
1334
|
+
return false;
|
|
1335
|
+
const packaged = /[/\\]app\.asar[/\\]/iu.test(host);
|
|
1336
|
+
return targets.some(target => packaged && target.startsWith('@deepseek-ai/')
|
|
1337
|
+
|| packageResolvableFrom(join(host, 'package.json'), target));
|
|
1338
|
+
}
|
|
1339
|
+
/** Whether `target` resolves from the host's own dependency tree. */
|
|
1340
|
+
function packageResolvableFrom(anchorPackageJson, target) {
|
|
1341
|
+
try {
|
|
1342
|
+
const paths = createRequire(anchorPackageJson).resolve.paths(target) ?? [];
|
|
1343
|
+
return paths.some(searchPath => existsSync(join(searchPath, target, 'package.json')));
|
|
1344
|
+
}
|
|
1345
|
+
catch {
|
|
1346
|
+
// An unreadable anchor says nothing about the target: treat as absent
|
|
1347
|
+
// rather than loadable, so the caller keeps its conservative answer.
|
|
1348
|
+
return false;
|
|
1349
|
+
}
|
|
1318
1350
|
}
|
|
1319
1351
|
/** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
|
|
1320
1352
|
export function pluginSubdirs(root) {
|
package/lib/recovery.js
CHANGED
|
@@ -32,10 +32,10 @@ import { spawn } from 'node:child_process';
|
|
|
32
32
|
import { appendFileSync, openSync, readFileSync } from 'node:fs';
|
|
33
33
|
import { createServer } from 'node:http';
|
|
34
34
|
import { connect } from 'node:net';
|
|
35
|
-
import { homedir } from 'node:os';
|
|
36
35
|
import { resolve } from 'node:path';
|
|
37
36
|
import { fileURLToPath } from 'node:url';
|
|
38
37
|
import { readJsonBody, sameOrigin, sendJson } from './http.js';
|
|
38
|
+
import { redactHomePaths } from './log-redaction.js';
|
|
39
39
|
import { disableRow, enableRow } from './patch.js';
|
|
40
40
|
import { addProfileBundle, removeProfileBundle } from './profile.js';
|
|
41
41
|
/** How long the replacement gets to bind the port before it is a failure. */
|
|
@@ -464,8 +464,7 @@ async function waitForPortFree(port, timeoutMs) {
|
|
|
464
464
|
* one unreadable line, which is the opposite of what the export is for.
|
|
465
465
|
*/
|
|
466
466
|
function redactLog(text) {
|
|
467
|
-
return text
|
|
468
|
-
.replaceAll(homedir(), '~')
|
|
467
|
+
return redactHomePaths(text)
|
|
469
468
|
.replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-***')
|
|
470
469
|
.replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, 'gh*_***')
|
|
471
470
|
.replace(/npm_[A-Za-z0-9]{16,}/g, 'npm_***')
|
package/lib/regions.js
CHANGED
|
@@ -32,7 +32,14 @@ export function asRegion(value) {
|
|
|
32
32
|
* pnpm's registry alone and setting it.
|
|
33
33
|
*/
|
|
34
34
|
export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Default npm registry for the China download region, no trailing slash.
|
|
37
|
+
*
|
|
38
|
+
* Exported so failure classifiers can recognize this host in pnpm's output
|
|
39
|
+
* (#926) without duplicating the URL.
|
|
40
|
+
*/
|
|
41
|
+
export const CHINA_NPM_REGISTRY = 'https://mirrors.cloud.tencent.com/npm';
|
|
42
|
+
const NPM_CHINA = CHINA_NPM_REGISTRY;
|
|
36
43
|
/**
|
|
37
44
|
* First public prefix used for unauthenticated GitHub reads, no trailing slash.
|
|
38
45
|
* Authenticated API calls and canonical codeload tarballs never use it.
|
package/lib/routes.js
CHANGED
|
@@ -4939,6 +4939,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4939
4939
|
if (droppedBundles.length > 0) {
|
|
4940
4940
|
logEvent('warn', 'update-bundles-dropped', `${name}: after this update ${droppedBundles.join(', ')} ${droppedBundles.length === 1 ? 'is' : 'are'} still declared in dependencies but no longer in dsh.profile.bundles, so ${droppedBundles.length === 1 ? 'it' : 'they'} will not load — the market did not write that change (#720)`);
|
|
4941
4941
|
}
|
|
4942
|
+
// #935: the desktop manager's removeBundle writes the bundle row
|
|
4943
|
+
// out FIRST and only then checks whether a live fiber still uses
|
|
4944
|
+
// the package — so a `bundle-in-use` refusal leaves the row gone
|
|
4945
|
+
// while dependencies and node_modules stay. The plugin keeps
|
|
4946
|
+
// running this session and silently fails to load next boot.
|
|
4947
|
+
// Restore the row, scoped to exactly that refusal and to this
|
|
4948
|
+
// operation's own target: the read-only stance in #720/#696
|
|
4949
|
+
// still holds for every other way a row can disappear.
|
|
4950
|
+
if (!ok && !cancelled && result.refusalCode === 'bundle-in-use') {
|
|
4951
|
+
const targetRowDropped = bundlesDroppedFromProfile(manifestBefore, config.profile, activeProfileDir, new Set(removedDeclaration === null ? [] : [removedDeclaration.name])).includes(name);
|
|
4952
|
+
if (targetRowDropped && addProfileBundle(activeProfileDir, name)) {
|
|
4953
|
+
logEvent('warn', 'update-bundles-restored', `${name}: the desktop app removed its bundle row before refusing the update (bundle-in-use) — restored it so the plugin still loads on the next boot (#935)`);
|
|
4954
|
+
}
|
|
4955
|
+
}
|
|
4942
4956
|
logEvent(ok || cancelled ? 'info' : 'error', 'update', `${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ` STALE(${staleReason ?? 'unknown'})` : ''}${ok || cancelled ? '' : ` err=${failureDetail(result)}`}`);
|
|
4943
4957
|
// A user-cancelled run is a quiet outcome, not an error.
|
|
4944
4958
|
sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
|
|
@@ -6128,7 +6142,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
6128
6142
|
// (a 404 for this package's own download: the classifier names
|
|
6129
6143
|
// the last path segment, the tarball file for that URL form).
|
|
6130
6144
|
const ownTarball = `${plainTarget.slice(plainTarget.lastIndexOf('/') + 1)}-${String(registryLatest)}.tgz`;
|
|
6131
|
-
|
|
6145
|
+
// `exact-version-missing` (#927) is the same lag shape with a
|
|
6146
|
+
// clearer sentence; install still falls back to the bare name
|
|
6147
|
+
// after withHoistRecovery's one same-argv retry loses.
|
|
6148
|
+
const notOnMirror = ((failure?.code === 'no-matching-version' || failure?.code === 'exact-version-missing')
|
|
6149
|
+
&& aboutThisPackage)
|
|
6132
6150
|
|| (failure?.code === 'fetch-404' && (failure.pkg === plainTarget || failure.pkg === ownTarball));
|
|
6133
6151
|
if (heldBack || notOnMirror) {
|
|
6134
6152
|
// The user asked for the young release anyway. That request is
|
package/lib/types/changelog.d.ts
CHANGED
|
@@ -63,6 +63,15 @@ export declare function sliceCommitsAt(items: CommitNote[], installed: string |
|
|
|
63
63
|
items: CommitNote[];
|
|
64
64
|
found: boolean;
|
|
65
65
|
};
|
|
66
|
+
/**
|
|
67
|
+
* The version a release tag names, when it names one.
|
|
68
|
+
*
|
|
69
|
+
* Tags come as `v1.2.3`, `1.2.3`, `@scope/pkg@1.2.3` or `pkg-v1.2.3`; the
|
|
70
|
+
* FIRST semver-shaped run is the version in all of them. A tag that names no
|
|
71
|
+
* version (`nightly`) returns null, and callers must treat "unknown" as "do
|
|
72
|
+
* not judge" rather than "older".
|
|
73
|
+
*/
|
|
74
|
+
export declare function releaseVersionOf(tag: string | null): string | null;
|
|
66
75
|
/** The repo url key an entry's update notes live under, or null. */
|
|
67
76
|
export declare function repoKeyOf(spec: string): string | null;
|
|
68
77
|
/**
|
|
@@ -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/profile.d.ts
CHANGED
|
@@ -412,7 +412,7 @@ export declare function conflictingEntryIds(profileDirectory: string, candidate:
|
|
|
412
412
|
* @param profileDirectory - resolved profile directory (host-authoritative under Desktop).
|
|
413
413
|
* @param name - installed package name.
|
|
414
414
|
*/
|
|
415
|
-
export declare function hasLoadableEntry(profileDirectory: string, name: string): boolean;
|
|
415
|
+
export declare function hasLoadableEntry(profileDirectory: string, name: string, hostDirectory?: string | null): boolean;
|
|
416
416
|
/** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
|
|
417
417
|
export declare function pluginSubdirs(root: string): string[];
|
|
418
418
|
/**
|
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.43",
|
|
5
5
|
"icon": "assets/logo.svg",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
package/src/changelog.ts
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
import { fileFromTarball } from './catalog-npm.ts'
|
|
25
25
|
import { marketFetch } from './net.ts'
|
|
26
26
|
import { activeRegion, routesFor } from './regions.ts'
|
|
27
|
-
import { profileDir, readGitResolutionCommit, readInstalled, readInstalledRepoEvidence, readLockCommits } from './profile.ts'
|
|
27
|
+
import { profileDir, readGitResolutionCommit, readInstalled, readInstalledRepoEvidence, readInstalledVersion, readLockCommits } from './profile.ts'
|
|
28
28
|
import { githubRepoOfRemote, hostedRepoKey, isGitHostedSpec, lookupRepoFromUrl, parseHostShorthand, parseSourceUrl, repoOf, repoOfTarget } from './sources.ts'
|
|
29
|
-
import { checkUpdates } from './updates.ts'
|
|
29
|
+
import { checkUpdates, compareVersions } from './updates.ts'
|
|
30
30
|
import { loadRegistry, type RegistryPlugin } from './registry.ts'
|
|
31
31
|
|
|
32
32
|
const UPDATES_PACKAGE = 'dsh-plugin-updates'
|
|
@@ -141,6 +141,20 @@ export function sliceCommitsAt(items: CommitNote[], installed: string | null): {
|
|
|
141
141
|
return { items: out, found: false }
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* The version a release tag names, when it names one.
|
|
146
|
+
*
|
|
147
|
+
* Tags come as `v1.2.3`, `1.2.3`, `@scope/pkg@1.2.3` or `pkg-v1.2.3`; the
|
|
148
|
+
* FIRST semver-shaped run is the version in all of them. A tag that names no
|
|
149
|
+
* version (`nightly`) returns null, and callers must treat "unknown" as "do
|
|
150
|
+
* not judge" rather than "older".
|
|
151
|
+
*/
|
|
152
|
+
export function releaseVersionOf(tag: string | null): string | null {
|
|
153
|
+
if (tag === null) return null
|
|
154
|
+
const match = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/.exec(tag)
|
|
155
|
+
return match?.[1] ?? null
|
|
156
|
+
}
|
|
157
|
+
|
|
144
158
|
/** The repo url key an entry's update notes live under, or null. */
|
|
145
159
|
export function repoKeyOf(spec: string): string | null {
|
|
146
160
|
const repo = githubIdentityOf(spec)?.split('#')[0] ?? null
|
|
@@ -450,7 +464,23 @@ export async function updateNotesFor(
|
|
|
450
464
|
}
|
|
451
465
|
|
|
452
466
|
if (entry?.release !== undefined && entry.release !== null && (entry.release.body !== '' || entry.release.tag !== null)) {
|
|
453
|
-
|
|
467
|
+
// The release feed is a DAILY snapshot while the update target is read
|
|
468
|
+
// live from npm, so the snapshot's newest release is often the version
|
|
469
|
+
// already installed (or older). Showing it next to "1.66.39 → 1.66.40"
|
|
470
|
+
// describes a change the user already has (#925). When the release
|
|
471
|
+
// names a version no newer than the installed one AND a better tier
|
|
472
|
+
// exists, take the better tier: the commit tail is sliced at this
|
|
473
|
+
// install's sha, and npm publish times are newer than the install by
|
|
474
|
+
// construction. With nothing better, the stale release still beats an
|
|
475
|
+
// empty dialog.
|
|
476
|
+
const installed = readInstalledVersion(profile, name, activeProfileDir)
|
|
477
|
+
const released = releaseVersionOf(entry.release.tag)
|
|
478
|
+
const behind = installed !== null && released !== null
|
|
479
|
+
&& (compareVersions(released, installed) ?? 0) <= 0
|
|
480
|
+
const hasCommits = (entry.commits?.length ?? 0) > 0
|
|
481
|
+
if (!(behind && (hasCommits || (specKey === null && npmName !== null)))) {
|
|
482
|
+
return { kind: 'release', release: entry.release }
|
|
483
|
+
}
|
|
454
484
|
}
|
|
455
485
|
if (entry?.commits !== undefined && entry.commits.length > 0) {
|
|
456
486
|
return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) }
|
package/src/install.ts
CHANGED
|
@@ -256,6 +256,16 @@ export async function withHoistRecovery(
|
|
|
256
256
|
// Do that retry ourselves instead of reporting a false failure.
|
|
257
257
|
logEvent('warn', 'install', `transient network failure while pnpm replayed the dependency tree (#83) — retrying once`)
|
|
258
258
|
result = await run(profile, pluginArgs)
|
|
259
|
+
} else if (
|
|
260
|
+
failure?.code === 'exact-version-missing'
|
|
261
|
+
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove' || pluginArgs[0] === 'update')
|
|
262
|
+
) {
|
|
263
|
+
// #927: market pinned name@x.y.z from /latest, but this registry's
|
|
264
|
+
// packument (often a mirror) does not list that version yet. Same argv
|
|
265
|
+
// once more gives pnpm a chance to re-fetch; if it still fails the
|
|
266
|
+
// classifier's message names "try later / switch Download region".
|
|
267
|
+
logEvent('warn', 'install', `exact version missing on this registry's packument (#927) — retrying once to re-fetch`)
|
|
268
|
+
result = await run(profile, pluginArgs)
|
|
259
269
|
} else if (
|
|
260
270
|
failure?.code === 'fetch-timeout'
|
|
261
271
|
&& (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
|
|
@@ -659,7 +669,10 @@ export async function validateAddedPlugins(
|
|
|
659
669
|
const packageDir = join(dir, 'node_modules', n)
|
|
660
670
|
// hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
|
|
661
671
|
// ship no entry of their own (#103) and must not be uninstalled here.
|
|
662
|
-
|
|
672
|
+
// The host install tree is passed too (#906): a carrier mounting only
|
|
673
|
+
// in-box packages resolves nowhere under the profile, and judging it by
|
|
674
|
+
// that alone deleted a package the host had just installed correctly.
|
|
675
|
+
if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n, hostDirectory ?? findDshInstallDir())) {
|
|
663
676
|
removedBroken.push(n)
|
|
664
677
|
await removeAndReconcile(run, profile, dir, n, hostDirectory)
|
|
665
678
|
continue
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
|
|
3
|
+
/** Mask only this user's home prefix, leaving the rest of each path intact. */
|
|
4
|
+
export function redactHomePaths(text: string): string {
|
|
5
|
+
const directory = homedir()
|
|
6
|
+
// Detect the home path's syntax rather than the host running the exporter.
|
|
7
|
+
// Windows is case-insensitive and accepts either separator, including mixed
|
|
8
|
+
// paths from pnpm. Repeated separators also cover JSON-escaped NDJSON history.
|
|
9
|
+
const windows = /^(?:[a-z]:[\\/]|[\\/]{2})/i.test(directory)
|
|
10
|
+
const home = directory.replace(windows ? /[\\/]+$/ : /\/+$/, '')
|
|
11
|
+
// A POSIX root home must not turn every absolute path into a home path.
|
|
12
|
+
if (home === '') return text
|
|
13
|
+
const escape = (part: string): string => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
14
|
+
const path = windows ? home.split(/[\\/]/).map(escape).join('[\\\\/]+') : escape(home)
|
|
15
|
+
// Do not match a home embedded in another path/text, or a sibling such as
|
|
16
|
+
// Alice-other or Alice.backup. File URLs still contain an absolute home;
|
|
17
|
+
// quotes/brackets allow bare homes in log prose.
|
|
18
|
+
const before = String.raw`(?:(?<![\p{L}\p{N}_./\\-])|(?<=\bfile://)|(?<=\bfile:///))`
|
|
19
|
+
// Trimming C:\ leaves C:, but only an absolute path belongs to that root.
|
|
20
|
+
const after = windows && /^[a-z]:$/i.test(home)
|
|
21
|
+
? String.raw`(?=[\\/])`
|
|
22
|
+
: windows ? String.raw`(?=$|[\\/\s"'\x60<>()[\]{},;:!?])` : String.raw`(?=$|[/\s"'\x60<>()[\]{},;:!?])`
|
|
23
|
+
return text.replace(new RegExp(before + path + after, windows ? 'giu' : 'gu'), '~')
|
|
24
|
+
}
|
package/src/log.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
16
16
|
import { dirname } from 'node:path'
|
|
17
|
-
import {
|
|
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/profile.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { createRequire } from 'node:module'
|
|
8
9
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
9
10
|
import { isDeepStrictEqual } from 'node:util'
|
|
10
11
|
import { JSON_SCHEMA, load } from 'js-yaml'
|
|
12
|
+
import { findDshInstallDir } from './dsh-install.ts'
|
|
11
13
|
import { resolveDshHome } from './home-paths.ts'
|
|
12
14
|
import { githubRemoteIdentities, githubRepoIdentities, hostedRepoKey, isGenerationLink, isGitHostedSpec, repoOfTarget } from './sources.ts'
|
|
13
15
|
|
|
@@ -1340,7 +1342,7 @@ export function conflictingEntryIds(
|
|
|
1340
1342
|
* @param profileDirectory - resolved profile directory (host-authoritative under Desktop).
|
|
1341
1343
|
* @param name - installed package name.
|
|
1342
1344
|
*/
|
|
1343
|
-
export function hasLoadableEntry(profileDirectory: string, name: string): boolean {
|
|
1345
|
+
export function hasLoadableEntry(profileDirectory: string, name: string, hostDirectory?: string | null): boolean {
|
|
1344
1346
|
const dir = join(profileDirectory, 'node_modules', name)
|
|
1345
1347
|
if (entryArtifactExists(dir)) return true
|
|
1346
1348
|
// A carrier is only sound when something it mounts is itself loadable.
|
|
@@ -1353,11 +1355,39 @@ export function hasLoadableEntry(profileDirectory: string, name: string): boolea
|
|
|
1353
1355
|
// exiting 0 was immediately followed by the market removing what it had
|
|
1354
1356
|
// just, correctly, installed.
|
|
1355
1357
|
const workspaceRoot = dirname(profileDirectory)
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1358
|
+
const targets = bundlePatchTargets(dir).filter(target => target !== name)
|
|
1359
|
+
if (targets.some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
|
|
1360
|
+
|| entryArtifactExists(join(dir, 'node_modules', target))
|
|
1361
|
+
|| entryArtifactExists(join(workspaceRoot, 'node_modules', target)))) return true
|
|
1362
|
+
// #906: the host's OWN packages live in its install tree, not in any
|
|
1363
|
+
// profile node_modules — `<install>/dsh/node_modules/...`, often inside
|
|
1364
|
+
// app.asar. A carrier whose patch mounts nothing but in-box packages
|
|
1365
|
+
// (@deepseek-ai/dsh-mcp-client and the like) therefore failed every root
|
|
1366
|
+
// above, was reported "broken", and — through validateAddedPlugins — was
|
|
1367
|
+
// uninstalled right after a successful install. `undefined` resolves the
|
|
1368
|
+
// install dir lazily (only carriers pay for it); `null` means "do not look".
|
|
1369
|
+
//
|
|
1370
|
+
// Inside app.asar the probe is blind for EVERY @deepseek-ai/ name (the
|
|
1371
|
+
// same measurement behind #676), so there identity decides instead of the
|
|
1372
|
+
// filesystem — the host publishes that scope, and our own composer loads
|
|
1373
|
+
// those packages from the archive at boot.
|
|
1374
|
+
const host = hostDirectory === undefined ? findDshInstallDir() : hostDirectory
|
|
1375
|
+
if (host === null || targets.length === 0) return false
|
|
1376
|
+
const packaged = /[/\\]app\.asar[/\\]/iu.test(host)
|
|
1377
|
+
return targets.some(target => packaged && target.startsWith('@deepseek-ai/')
|
|
1378
|
+
|| packageResolvableFrom(join(host, 'package.json'), target))
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
/** Whether `target` resolves from the host's own dependency tree. */
|
|
1382
|
+
function packageResolvableFrom(anchorPackageJson: string, target: string): boolean {
|
|
1383
|
+
try {
|
|
1384
|
+
const paths = createRequire(anchorPackageJson).resolve.paths(target) ?? []
|
|
1385
|
+
return paths.some(searchPath => existsSync(join(searchPath, target, 'package.json')))
|
|
1386
|
+
} catch {
|
|
1387
|
+
// An unreadable anchor says nothing about the target: treat as absent
|
|
1388
|
+
// rather than loadable, so the caller keeps its conservative answer.
|
|
1389
|
+
return false
|
|
1390
|
+
}
|
|
1361
1391
|
}
|
|
1362
1392
|
|
|
1363
1393
|
/** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
|
package/src/recovery.ts
CHANGED
|
@@ -33,10 +33,10 @@ import { spawn } from 'node:child_process'
|
|
|
33
33
|
import { appendFileSync, openSync, readFileSync } from 'node:fs'
|
|
34
34
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
35
35
|
import { connect } from 'node:net'
|
|
36
|
-
import { homedir } from 'node:os'
|
|
37
36
|
import { resolve } from 'node:path'
|
|
38
37
|
import { fileURLToPath } from 'node:url'
|
|
39
38
|
import { readJsonBody, sameOrigin, sendJson } from './http.ts'
|
|
39
|
+
import { redactHomePaths } from './log-redaction.ts'
|
|
40
40
|
import { disableRow, enableRow } from './patch.ts'
|
|
41
41
|
import { addProfileBundle, removeProfileBundle } from './profile.ts'
|
|
42
42
|
|
|
@@ -566,8 +566,7 @@ async function waitForPortFree(port: number, timeoutMs: number): Promise<boolean
|
|
|
566
566
|
* one unreadable line, which is the opposite of what the export is for.
|
|
567
567
|
*/
|
|
568
568
|
function redactLog(text: string): string {
|
|
569
|
-
return text
|
|
570
|
-
.replaceAll(homedir(), '~')
|
|
569
|
+
return redactHomePaths(text)
|
|
571
570
|
.replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-***')
|
|
572
571
|
.replace(/gh[pousr]_[A-Za-z0-9]{16,}/g, 'gh*_***')
|
|
573
572
|
.replace(/npm_[A-Za-z0-9]{16,}/g, 'npm_***')
|
package/src/regions.ts
CHANGED
|
@@ -38,7 +38,14 @@ export function asRegion(value: unknown): Region | null {
|
|
|
38
38
|
* pnpm's registry alone and setting it.
|
|
39
39
|
*/
|
|
40
40
|
export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
41
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Default npm registry for the China download region, no trailing slash.
|
|
43
|
+
*
|
|
44
|
+
* Exported so failure classifiers can recognize this host in pnpm's output
|
|
45
|
+
* (#926) without duplicating the URL.
|
|
46
|
+
*/
|
|
47
|
+
export const CHINA_NPM_REGISTRY = 'https://mirrors.cloud.tencent.com/npm'
|
|
48
|
+
const NPM_CHINA = CHINA_NPM_REGISTRY
|
|
42
49
|
|
|
43
50
|
/**
|
|
44
51
|
* First public prefix used for unauthenticated GitHub reads, no trailing slash.
|
package/src/routes.ts
CHANGED
|
@@ -5167,6 +5167,24 @@ sendJson(response, 200, { updates })
|
|
|
5167
5167
|
logEvent('warn', 'update-bundles-dropped',
|
|
5168
5168
|
`${name}: after this update ${droppedBundles.join(', ')} ${droppedBundles.length === 1 ? 'is' : 'are'} still declared in dependencies but no longer in dsh.profile.bundles, so ${droppedBundles.length === 1 ? 'it' : 'they'} will not load — the market did not write that change (#720)`)
|
|
5169
5169
|
}
|
|
5170
|
+
// #935: the desktop manager's removeBundle writes the bundle row
|
|
5171
|
+
// out FIRST and only then checks whether a live fiber still uses
|
|
5172
|
+
// the package — so a `bundle-in-use` refusal leaves the row gone
|
|
5173
|
+
// while dependencies and node_modules stay. The plugin keeps
|
|
5174
|
+
// running this session and silently fails to load next boot.
|
|
5175
|
+
// Restore the row, scoped to exactly that refusal and to this
|
|
5176
|
+
// operation's own target: the read-only stance in #720/#696
|
|
5177
|
+
// still holds for every other way a row can disappear.
|
|
5178
|
+
if (!ok && !cancelled && result.refusalCode === 'bundle-in-use') {
|
|
5179
|
+
const targetRowDropped = bundlesDroppedFromProfile(
|
|
5180
|
+
manifestBefore, config.profile, activeProfileDir,
|
|
5181
|
+
new Set(removedDeclaration === null ? [] : [removedDeclaration.name]),
|
|
5182
|
+
).includes(name)
|
|
5183
|
+
if (targetRowDropped && addProfileBundle(activeProfileDir, name)) {
|
|
5184
|
+
logEvent('warn', 'update-bundles-restored',
|
|
5185
|
+
`${name}: the desktop app removed its bundle row before refusing the update (bundle-in-use) — restored it so the plugin still loads on the next boot (#935)`)
|
|
5186
|
+
}
|
|
5187
|
+
}
|
|
5170
5188
|
logEvent(ok || cancelled ? 'info' : 'error', 'update',
|
|
5171
5189
|
`${name} -> ${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${stale ? ` STALE(${staleReason ?? 'unknown'})` : ''}${ok || cancelled ? '' : ` err=${failureDetail(result)}`}`)
|
|
5172
5190
|
// A user-cancelled run is a quiet outcome, not an error.
|
|
@@ -6355,7 +6373,13 @@ sendJson(response, 200, { updates })
|
|
|
6355
6373
|
// (a 404 for this package's own download: the classifier names
|
|
6356
6374
|
// the last path segment, the tarball file for that URL form).
|
|
6357
6375
|
const ownTarball = `${plainTarget.slice(plainTarget.lastIndexOf('/') + 1)}-${String(registryLatest)}.tgz`
|
|
6358
|
-
|
|
6376
|
+
// `exact-version-missing` (#927) is the same lag shape with a
|
|
6377
|
+
// clearer sentence; install still falls back to the bare name
|
|
6378
|
+
// after withHoistRecovery's one same-argv retry loses.
|
|
6379
|
+
const notOnMirror = (
|
|
6380
|
+
(failure?.code === 'no-matching-version' || failure?.code === 'exact-version-missing')
|
|
6381
|
+
&& aboutThisPackage
|
|
6382
|
+
)
|
|
6359
6383
|
|| (failure?.code === 'fetch-404' && (failure.pkg === plainTarget || failure.pkg === ownTarball))
|
|
6360
6384
|
if (heldBack || notOnMirror) {
|
|
6361
6385
|
// The user asked for the young release anyway. That request is
|
package/src/updates.ts
CHANGED
|
@@ -363,7 +363,20 @@ async function tagVersion(name: string, tag: string): Promise<string | null> {
|
|
|
363
363
|
export async function fetchNpmLatest(name: string): Promise<string | null> {
|
|
364
364
|
try {
|
|
365
365
|
const meta = (await fetchJson(npmUrl(`${encodeURIComponent(name)}/latest`))) as { version?: string }
|
|
366
|
-
|
|
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
|
}
|