dshmarket 1.66.40 → 1.66.42
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 +5 -0
- package/lib/changelog.js +33 -3
- package/lib/dsh-cli.js +36 -1
- package/lib/install.js +4 -1
- package/lib/official-desktop.js +36 -1
- package/lib/pnpm-compat.js +15 -0
- package/lib/profile.js +37 -5
- package/lib/types/changelog.d.ts +9 -0
- package/lib/types/dsh-cli.d.ts +15 -0
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/lib/types/profile.d.ts +1 -1
- package/package.json +1 -1
- package/src/changelog.ts +33 -3
- package/src/client/MarketSection.tsx +14 -0
- package/src/client/globals.d.ts +18 -0
- package/src/dsh-cli.ts +33 -1
- package/src/install.ts +4 -1
- package/src/official-desktop.ts +38 -1
- package/src/pnpm-compat.ts +16 -1
- package/src/profile.ts +36 -6
package/client/client.js
CHANGED
|
@@ -10017,6 +10017,11 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
10017
10017
|
setRestarting(true);
|
|
10018
10018
|
setInstallError(null);
|
|
10019
10019
|
setRecovery(null);
|
|
10020
|
+
const shellRestart = typeof window === "undefined" ? void 0 : window.__dshHostShell?.restart;
|
|
10021
|
+
if (typeof shellRestart === "function") try {
|
|
10022
|
+
shellRestart();
|
|
10023
|
+
return;
|
|
10024
|
+
} catch {}
|
|
10020
10025
|
const awaitNewBoot = () => {
|
|
10021
10026
|
const deadline = Date.now() + 6e4;
|
|
10022
10027
|
const poll = () => {
|
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/dsh-cli.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the agent's sandboxed executor and denies writes to the profile directory.
|
|
8
8
|
*/
|
|
9
9
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
|
-
import { existsSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
13
13
|
import { logEvent } from './log.js';
|
|
@@ -550,6 +550,37 @@ export function killChild(child) {
|
|
|
550
550
|
/** The child of the operation currently running, for /dsh-market/cancel. */
|
|
551
551
|
let activeChild = null;
|
|
552
552
|
let cancelRequested = false;
|
|
553
|
+
/**
|
|
554
|
+
* Remove the profile's `package.json.lock` when it is provably ours (#914).
|
|
555
|
+
*
|
|
556
|
+
* The dsh CLI writes that lock through the host's atomic-write, whose
|
|
557
|
+
* `finally` releases it. A timeout kills the whole tree with `taskkill /F`,
|
|
558
|
+
* so that release never runs and the file outlives the child — and on
|
|
559
|
+
* Windows the host cannot reliably tell a dead PID from a live one, so every
|
|
560
|
+
* later change waits the full lock timeout and fails.
|
|
561
|
+
*
|
|
562
|
+
* Only a lock naming OUR killed child is removed: that PID is provably dead
|
|
563
|
+
* because we just killed its whole tree, and it cannot belong to another
|
|
564
|
+
* operation. A lock naming anything else is left for the classifier to name
|
|
565
|
+
* (#914), because deleting a live operation's lock would race it.
|
|
566
|
+
*/
|
|
567
|
+
export function clearOwnedProfileLock(profile, pid) {
|
|
568
|
+
if (pid === undefined)
|
|
569
|
+
return false;
|
|
570
|
+
try {
|
|
571
|
+
const lock = join(profileDir(profile), 'package.json.lock');
|
|
572
|
+
const recorded = Number(readFileSync(lock, 'utf8').trim());
|
|
573
|
+
if (!Number.isInteger(recorded) || recorded !== pid)
|
|
574
|
+
return false;
|
|
575
|
+
rmSync(lock, { force: true });
|
|
576
|
+
logEvent('warn', 'install', `the timed-out operation left ${lock} behind (pid ${pid}, already killed with its tree) — removed it so the next change is not blocked (#914)`);
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// Absent, unreadable, or removed by the child on its way out: nothing to do.
|
|
581
|
+
return false;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
553
584
|
let activeDesktopOperation = null;
|
|
554
585
|
/**
|
|
555
586
|
* Kill a child and its whole tree, gracefully where the platform allows:
|
|
@@ -1047,6 +1078,10 @@ export function runDshPlugin(profile, pluginArgs) {
|
|
|
1047
1078
|
});
|
|
1048
1079
|
child.on('close', (code) => {
|
|
1049
1080
|
clearTimeout(timer);
|
|
1081
|
+
// A timed-out run was killed hard, so the host's lock release in the
|
|
1082
|
+
// child's `finally` never ran. Clear only OUR child's lock (#914).
|
|
1083
|
+
if (timedOut)
|
|
1084
|
+
clearOwnedProfileLock(profile, child.pid);
|
|
1050
1085
|
progress.active = false;
|
|
1051
1086
|
progress.cancelling = false;
|
|
1052
1087
|
if (activeChild === child)
|
package/lib/install.js
CHANGED
|
@@ -662,7 +662,10 @@ export async function validateAddedPlugins(run, profile, before, explicitDir, ho
|
|
|
662
662
|
const packageDir = join(dir, 'node_modules', n);
|
|
663
663
|
// hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
|
|
664
664
|
// ship no entry of their own (#103) and must not be uninstalled here.
|
|
665
|
-
|
|
665
|
+
// The host install tree is passed too (#906): a carrier mounting only
|
|
666
|
+
// in-box packages resolves nowhere under the profile, and judging it by
|
|
667
|
+
// that alone deleted a package the host had just installed correctly.
|
|
668
|
+
if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n, hostDirectory ?? findDshInstallDir())) {
|
|
666
669
|
removedBroken.push(n);
|
|
667
670
|
await removeAndReconcile(run, profile, dir, n, hostDirectory);
|
|
668
671
|
continue;
|
package/lib/official-desktop.js
CHANGED
|
@@ -11,6 +11,30 @@ export const OFFICIAL_PAGE_HINT = '官方桌面客户端的这个插件操作需
|
|
|
11
11
|
function failure(message, exitCode = 1) {
|
|
12
12
|
return { exitCode, timedOut: false, stdout: '', stderr: message, cancelled: false };
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* The host's own pre-install diagnostic, when it wrote one.
|
|
16
|
+
*
|
|
17
|
+
* On dsh 0.2.1 the manager runs checks of its own before pnpm starts and
|
|
18
|
+
* reports them as `dsh: …` in an `operation-error`'s `diagnostic` (#912).
|
|
19
|
+
*/
|
|
20
|
+
function hostDiagnostic(error) {
|
|
21
|
+
const diagnostic = error?.diagnostic;
|
|
22
|
+
return typeof diagnostic === 'string' && diagnostic.startsWith('dsh: ') ? diagnostic : undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Whether the host stopped before pnpm ran, on a check of its own (#912).
|
|
26
|
+
*
|
|
27
|
+
* `checkGithubConnection` refuses an install after five seconds without
|
|
28
|
+
* touching the profile; so does the compatibility gate (`installation
|
|
29
|
+
* rejected:`). Neither is a write, and treating them as one sent the update
|
|
30
|
+
* route into a rollback of an untouched profile — a false "restoration
|
|
31
|
+
* could not be verified" alarm that buried the real reason.
|
|
32
|
+
*/
|
|
33
|
+
function prePnpmHostFailure(result) {
|
|
34
|
+
const diagnostic = hostDiagnostic(result.error);
|
|
35
|
+
return diagnostic !== undefined
|
|
36
|
+
&& /^dsh: (?:connection to \S+ timed out|installation rejected:)/.test(diagnostic);
|
|
37
|
+
}
|
|
14
38
|
function detail(error) {
|
|
15
39
|
if (typeof error === 'string')
|
|
16
40
|
return error;
|
|
@@ -26,6 +50,15 @@ function detail(error) {
|
|
|
26
50
|
const message = error.message;
|
|
27
51
|
if (typeof message === 'string' && message !== '')
|
|
28
52
|
return message;
|
|
53
|
+
const diagnostic = hostDiagnostic(error);
|
|
54
|
+
if (diagnostic !== undefined) {
|
|
55
|
+
if (/^dsh: connection to \S+ timed out/.test(diagnostic)) {
|
|
56
|
+
// The 5 s is the host's own pre-install reachability check, not the
|
|
57
|
+
// install failing: say which knob moves it (#912).
|
|
58
|
+
return `${diagnostic}\n\n宿主在开始安装前自己检查了 GitHub 的连通性,5 秒内没连上就放弃了这次安装,pnpm 还没有开始,profile 没有任何改动。如果手动 \`git ls-remote\` 能连上(例如已给 git 配好代理或换过 TLS 后端),把宿主 plugin-manager 的 \`githubConnectionTimeoutMs\` 调大再试(cordis.yml 里该插件的 config)。 / the host checks GitHub reachability before installing and gave up after five seconds; pnpm never started and the profile is unchanged. If git itself can reach GitHub (a proxy, or a different TLS backend), raise the host plugin-manager's \`githubConnectionTimeoutMs\` (its config in cordis.yml) and try again.`;
|
|
59
|
+
}
|
|
60
|
+
return diagnostic;
|
|
61
|
+
}
|
|
29
62
|
return JSON.stringify(error);
|
|
30
63
|
}
|
|
31
64
|
/**
|
|
@@ -250,7 +283,9 @@ _profileDirectory) {
|
|
|
250
283
|
cancelled: current.cancelled || result.application === 'cancelled',
|
|
251
284
|
// No package result means the manager stopped before it ran pnpm: the
|
|
252
285
|
// profile is untouched, and the route must not try to "restore" it.
|
|
253
|
-
|
|
286
|
+
// A pre-install host check (#912) is the same shape even when the
|
|
287
|
+
// manager attached a package result to its refusal.
|
|
288
|
+
wroteNothing: !ok && (result.packageResult === undefined || prePnpmHostFailure(result)),
|
|
254
289
|
// The manager's own code, kept machine-readable (#909): a client can
|
|
255
290
|
// tell a self-update refusal from an ordinary failure.
|
|
256
291
|
...(ok ? {} : { refusalCode: managementCode(result.error) }),
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -191,6 +191,21 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
191
191
|
: `pnpm ${minutes} 分钟没有任何输出,被终止了,什么都没装上。通常是网络卡住了。检查网络后重试。 / pnpm printed nothing for ${minutes} minutes and was stopped, so nothing was installed. This usually means the network stalled. Check the connection and try again.`,
|
|
192
192
|
};
|
|
193
193
|
}
|
|
194
|
+
// #914: the host's atomic-write could not take the profile's write lock.
|
|
195
|
+
// Either a concurrent operation is legitimately holding it, or an earlier
|
|
196
|
+
// update was killed hard and left the file behind — on Windows the host
|
|
197
|
+
// cannot always tell a dead PID from a live one, so it never takes over.
|
|
198
|
+
// The market does not delete the file: it belongs to the host, and a wrong
|
|
199
|
+
// deletion races a live operation. Say where it is and what is safe.
|
|
200
|
+
const writerLock = /timed out waiting for the writer lock at "?([^"\n]+)"?/.exec(output);
|
|
201
|
+
if (writerLock !== null) {
|
|
202
|
+
const path = writerLock[1].trim();
|
|
203
|
+
return {
|
|
204
|
+
code: 'profile-write-lock',
|
|
205
|
+
recoverable: true,
|
|
206
|
+
message: `profile 的写锁等了太久没等到:${path}。要么另一个插件操作还在跑(等它结束再试),要么某次被中断的更新把锁留在了那里。确认没有安装或更新正在进行后,删掉这个文件再重试即可;市场不会替你去动它。 / the profile's writer lock was never released: ${path}. Either another plugin operation is still running (wait for it), or an interrupted update left the lock behind. Once nothing is installing or updating, delete that file and try again; the market does not remove it for you.`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
194
209
|
if (output.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')
|
|
195
210
|
|| output.includes('ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF')) {
|
|
196
211
|
return {
|
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/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
|
/**
|
package/lib/types/dsh-cli.d.ts
CHANGED
|
@@ -299,6 +299,21 @@ export interface DesktopPluginRuntime extends PluginCommandRuntime {
|
|
|
299
299
|
* (Contributed in #7 by @mraing.)
|
|
300
300
|
*/
|
|
301
301
|
export declare function killChild(child: ChildProcess): void;
|
|
302
|
+
/**
|
|
303
|
+
* Remove the profile's `package.json.lock` when it is provably ours (#914).
|
|
304
|
+
*
|
|
305
|
+
* The dsh CLI writes that lock through the host's atomic-write, whose
|
|
306
|
+
* `finally` releases it. A timeout kills the whole tree with `taskkill /F`,
|
|
307
|
+
* so that release never runs and the file outlives the child — and on
|
|
308
|
+
* Windows the host cannot reliably tell a dead PID from a live one, so every
|
|
309
|
+
* later change waits the full lock timeout and fails.
|
|
310
|
+
*
|
|
311
|
+
* Only a lock naming OUR killed child is removed: that PID is provably dead
|
|
312
|
+
* because we just killed its whole tree, and it cannot belong to another
|
|
313
|
+
* operation. A lock naming anything else is left for the classifier to name
|
|
314
|
+
* (#914), because deleting a live operation's lock would race it.
|
|
315
|
+
*/
|
|
316
|
+
export declare function clearOwnedProfileLock(profile: string, pid: number | undefined): boolean;
|
|
302
317
|
/**
|
|
303
318
|
* Cancel the plugin command currently running.
|
|
304
319
|
* @returns true when there was one to cancel.
|
|
@@ -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';
|
|
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';
|
|
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/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.42",
|
|
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) }
|
|
@@ -3560,6 +3560,20 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
3560
3560
|
setRestarting(true)
|
|
3561
3561
|
setInstallError(null)
|
|
3562
3562
|
setRecovery(null)
|
|
3563
|
+
// A shell that runs this host as its own child process owns the lifecycle:
|
|
3564
|
+
// it stops the process it started and relaunches its window. The helper
|
|
3565
|
+
// below would be a second lifecycle competing with it — two processes, one
|
|
3566
|
+
// port — so where the shell is there, ask it instead of spawning anything.
|
|
3567
|
+
const shellRestart = typeof window === 'undefined' ? undefined : window.__dshHostShell?.restart
|
|
3568
|
+
if (typeof shellRestart === 'function') {
|
|
3569
|
+
try {
|
|
3570
|
+
shellRestart()
|
|
3571
|
+
return
|
|
3572
|
+
} catch {
|
|
3573
|
+
// A shell that published the bridge and then threw is not a restart:
|
|
3574
|
+
// fall through to the host's own route, which is the pre-existing path.
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3563
3577
|
const awaitNewBoot = () => {
|
|
3564
3578
|
const deadline = Date.now() + 60000
|
|
3565
3579
|
const poll = () => {
|
package/src/client/globals.d.ts
CHANGED
|
@@ -10,4 +10,22 @@ interface Window {
|
|
|
10
10
|
__DSH_BOOT__?: {
|
|
11
11
|
entries?: Array<{ id: string }>
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The host shell's own lifecycle, published by a shell that runs the host as
|
|
15
|
+
* a child process (a Tauri or Electron app that spawned `dsh web`). Optional:
|
|
16
|
+
* absent on every host that is not wrapped in one.
|
|
17
|
+
*
|
|
18
|
+
* A shell restart is not the same operation as the market's own: the shell
|
|
19
|
+
* stops the process it started and relaunches its own window, so a detached
|
|
20
|
+
* helper spawned by the host would be a second, competing lifecycle. When
|
|
21
|
+
* the shell is there, the restart is asked for through it instead.
|
|
22
|
+
*/
|
|
23
|
+
__dshHostShell?: {
|
|
24
|
+
/**
|
|
25
|
+
* Restart the host the way the shell's own menu item does. Returns
|
|
26
|
+
* nothing on purpose: the window it answers on is about to go away, so
|
|
27
|
+
* there is no state a caller could usefully read back.
|
|
28
|
+
*/
|
|
29
|
+
restart?: () => void
|
|
30
|
+
}
|
|
13
31
|
}
|
package/src/dsh-cli.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { spawn, spawnSync } from 'node:child_process'
|
|
11
11
|
import type { ChildProcess, SpawnOptions } from 'node:child_process'
|
|
12
|
-
import { existsSync } from 'node:fs'
|
|
12
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
15
15
|
import { logEvent } from './log.ts'
|
|
@@ -723,6 +723,35 @@ export function killChild(child: ChildProcess): void {
|
|
|
723
723
|
let activeChild: ChildProcess | null = null
|
|
724
724
|
let cancelRequested = false
|
|
725
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Remove the profile's `package.json.lock` when it is provably ours (#914).
|
|
728
|
+
*
|
|
729
|
+
* The dsh CLI writes that lock through the host's atomic-write, whose
|
|
730
|
+
* `finally` releases it. A timeout kills the whole tree with `taskkill /F`,
|
|
731
|
+
* so that release never runs and the file outlives the child — and on
|
|
732
|
+
* Windows the host cannot reliably tell a dead PID from a live one, so every
|
|
733
|
+
* later change waits the full lock timeout and fails.
|
|
734
|
+
*
|
|
735
|
+
* Only a lock naming OUR killed child is removed: that PID is provably dead
|
|
736
|
+
* because we just killed its whole tree, and it cannot belong to another
|
|
737
|
+
* operation. A lock naming anything else is left for the classifier to name
|
|
738
|
+
* (#914), because deleting a live operation's lock would race it.
|
|
739
|
+
*/
|
|
740
|
+
export function clearOwnedProfileLock(profile: string, pid: number | undefined): boolean {
|
|
741
|
+
if (pid === undefined) return false
|
|
742
|
+
try {
|
|
743
|
+
const lock = join(profileDir(profile), 'package.json.lock')
|
|
744
|
+
const recorded = Number(readFileSync(lock, 'utf8').trim())
|
|
745
|
+
if (!Number.isInteger(recorded) || recorded !== pid) return false
|
|
746
|
+
rmSync(lock, { force: true })
|
|
747
|
+
logEvent('warn', 'install', `the timed-out operation left ${lock} behind (pid ${pid}, already killed with its tree) — removed it so the next change is not blocked (#914)`)
|
|
748
|
+
return true
|
|
749
|
+
} catch {
|
|
750
|
+
// Absent, unreadable, or removed by the child on its way out: nothing to do.
|
|
751
|
+
return false
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
726
755
|
interface ActiveDesktopOperation {
|
|
727
756
|
readonly owner: symbol
|
|
728
757
|
readonly cancel: () => void
|
|
@@ -1277,6 +1306,9 @@ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<Ins
|
|
|
1277
1306
|
})
|
|
1278
1307
|
child.on('close', (code) => {
|
|
1279
1308
|
clearTimeout(timer)
|
|
1309
|
+
// A timed-out run was killed hard, so the host's lock release in the
|
|
1310
|
+
// child's `finally` never ran. Clear only OUR child's lock (#914).
|
|
1311
|
+
if (timedOut) clearOwnedProfileLock(profile, child.pid)
|
|
1280
1312
|
progress.active = false
|
|
1281
1313
|
progress.cancelling = false
|
|
1282
1314
|
if (activeChild === child) activeChild = null
|
package/src/install.ts
CHANGED
|
@@ -659,7 +659,10 @@ export async function validateAddedPlugins(
|
|
|
659
659
|
const packageDir = join(dir, 'node_modules', n)
|
|
660
660
|
// hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
|
|
661
661
|
// ship no entry of their own (#103) and must not be uninstalled here.
|
|
662
|
-
|
|
662
|
+
// The host install tree is passed too (#906): a carrier mounting only
|
|
663
|
+
// in-box packages resolves nowhere under the profile, and judging it by
|
|
664
|
+
// that alone deleted a package the host had just installed correctly.
|
|
665
|
+
if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n, hostDirectory ?? findDshInstallDir())) {
|
|
663
666
|
removedBroken.push(n)
|
|
664
667
|
await removeAndReconcile(run, profile, dir, n, hostDirectory)
|
|
665
668
|
continue
|
package/src/official-desktop.ts
CHANGED
|
@@ -27,6 +27,32 @@ function failure(message: string, exitCode = 1): InstallResult {
|
|
|
27
27
|
return { exitCode, timedOut: false, stdout: '', stderr: message, cancelled: false }
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The host's own pre-install diagnostic, when it wrote one.
|
|
32
|
+
*
|
|
33
|
+
* On dsh 0.2.1 the manager runs checks of its own before pnpm starts and
|
|
34
|
+
* reports them as `dsh: …` in an `operation-error`'s `diagnostic` (#912).
|
|
35
|
+
*/
|
|
36
|
+
function hostDiagnostic(error: unknown): string | undefined {
|
|
37
|
+
const diagnostic = (error as { diagnostic?: unknown } | null | undefined)?.diagnostic
|
|
38
|
+
return typeof diagnostic === 'string' && diagnostic.startsWith('dsh: ') ? diagnostic : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whether the host stopped before pnpm ran, on a check of its own (#912).
|
|
43
|
+
*
|
|
44
|
+
* `checkGithubConnection` refuses an install after five seconds without
|
|
45
|
+
* touching the profile; so does the compatibility gate (`installation
|
|
46
|
+
* rejected:`). Neither is a write, and treating them as one sent the update
|
|
47
|
+
* route into a rollback of an untouched profile — a false "restoration
|
|
48
|
+
* could not be verified" alarm that buried the real reason.
|
|
49
|
+
*/
|
|
50
|
+
function prePnpmHostFailure(result: ManagedResult): boolean {
|
|
51
|
+
const diagnostic = hostDiagnostic(result.error)
|
|
52
|
+
return diagnostic !== undefined
|
|
53
|
+
&& /^dsh: (?:connection to \S+ timed out|installation rejected:)/.test(diagnostic)
|
|
54
|
+
}
|
|
55
|
+
|
|
30
56
|
function detail(error: unknown): string {
|
|
31
57
|
if (typeof error === 'string') return error
|
|
32
58
|
if (error instanceof Error) return error.message
|
|
@@ -37,6 +63,15 @@ function detail(error: unknown): string {
|
|
|
37
63
|
if (copy !== undefined) return `${copy.zh} / ${copy.en} (${code})`
|
|
38
64
|
const message = (error as { message?: unknown }).message
|
|
39
65
|
if (typeof message === 'string' && message !== '') return message
|
|
66
|
+
const diagnostic = hostDiagnostic(error)
|
|
67
|
+
if (diagnostic !== undefined) {
|
|
68
|
+
if (/^dsh: connection to \S+ timed out/.test(diagnostic)) {
|
|
69
|
+
// The 5 s is the host's own pre-install reachability check, not the
|
|
70
|
+
// install failing: say which knob moves it (#912).
|
|
71
|
+
return `${diagnostic}\n\n宿主在开始安装前自己检查了 GitHub 的连通性,5 秒内没连上就放弃了这次安装,pnpm 还没有开始,profile 没有任何改动。如果手动 \`git ls-remote\` 能连上(例如已给 git 配好代理或换过 TLS 后端),把宿主 plugin-manager 的 \`githubConnectionTimeoutMs\` 调大再试(cordis.yml 里该插件的 config)。 / the host checks GitHub reachability before installing and gave up after five seconds; pnpm never started and the profile is unchanged. If git itself can reach GitHub (a proxy, or a different TLS backend), raise the host plugin-manager's \`githubConnectionTimeoutMs\` (its config in cordis.yml) and try again.`
|
|
72
|
+
}
|
|
73
|
+
return diagnostic
|
|
74
|
+
}
|
|
40
75
|
return JSON.stringify(error)
|
|
41
76
|
}
|
|
42
77
|
|
|
@@ -268,7 +303,9 @@ export function createOfficialDesktopRuntime(
|
|
|
268
303
|
cancelled: current.cancelled || result.application === 'cancelled',
|
|
269
304
|
// No package result means the manager stopped before it ran pnpm: the
|
|
270
305
|
// profile is untouched, and the route must not try to "restore" it.
|
|
271
|
-
|
|
306
|
+
// A pre-install host check (#912) is the same shape even when the
|
|
307
|
+
// manager attached a package result to its refusal.
|
|
308
|
+
wroteNothing: !ok && (result.packageResult === undefined || prePnpmHostFailure(result)),
|
|
272
309
|
// The manager's own code, kept machine-readable (#909): a client can
|
|
273
310
|
// tell a self-update refusal from an ordinary failure.
|
|
274
311
|
...(ok ? {} : { refusalCode: managementCode(result.error) }),
|
package/src/pnpm-compat.ts
CHANGED
|
@@ -51,7 +51,7 @@ export interface PnpmFailure {
|
|
|
51
51
|
| 'profile-file-locked'
|
|
52
52
|
| 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom' | 'ssh-auth-failed'
|
|
53
53
|
| 'host-incompatible-install'
|
|
54
|
-
| 'lockfile-broken' | 'lockfile-outdated' | 'pnpm-stalled'
|
|
54
|
+
| 'lockfile-broken' | 'lockfile-outdated' | 'pnpm-stalled' | 'profile-write-lock'
|
|
55
55
|
/** Bilingual, actionable message shown to the user instead of the raw wall of text. */
|
|
56
56
|
message: string
|
|
57
57
|
/** True when re-running `pnpm install` in the profile is the documented recovery. */
|
|
@@ -229,6 +229,21 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
|
|
|
229
229
|
: `pnpm ${minutes} 分钟没有任何输出,被终止了,什么都没装上。通常是网络卡住了。检查网络后重试。 / pnpm printed nothing for ${minutes} minutes and was stopped, so nothing was installed. This usually means the network stalled. Check the connection and try again.`,
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
|
+
// #914: the host's atomic-write could not take the profile's write lock.
|
|
233
|
+
// Either a concurrent operation is legitimately holding it, or an earlier
|
|
234
|
+
// update was killed hard and left the file behind — on Windows the host
|
|
235
|
+
// cannot always tell a dead PID from a live one, so it never takes over.
|
|
236
|
+
// The market does not delete the file: it belongs to the host, and a wrong
|
|
237
|
+
// deletion races a live operation. Say where it is and what is safe.
|
|
238
|
+
const writerLock = /timed out waiting for the writer lock at "?([^"\n]+)"?/.exec(output)
|
|
239
|
+
if (writerLock !== null) {
|
|
240
|
+
const path = writerLock[1]!.trim()
|
|
241
|
+
return {
|
|
242
|
+
code: 'profile-write-lock',
|
|
243
|
+
recoverable: true,
|
|
244
|
+
message: `profile 的写锁等了太久没等到:${path}。要么另一个插件操作还在跑(等它结束再试),要么某次被中断的更新把锁留在了那里。确认没有安装或更新正在进行后,删掉这个文件再重试即可;市场不会替你去动它。 / the profile's writer lock was never released: ${path}. Either another plugin operation is still running (wait for it), or an interrupted update left the lock behind. Once nothing is installing or updating, delete that file and try again; the market does not remove it for you.`,
|
|
245
|
+
}
|
|
246
|
+
}
|
|
232
247
|
if (output.includes('ERR_PNPM_PUBLIC_HOIST_PATTERN_DIFF')
|
|
233
248
|
|| output.includes('ERR_PNPM_VIRTUAL_STORE_DIR_MAX_LENGTH_DIFF')) {
|
|
234
249
|
return {
|
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. */
|