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 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
- return { kind: 'release', release: entry.release };
424
+ // The release feed is a DAILY snapshot while the update target is read
425
+ // live from npm, so the snapshot's newest release is often the version
426
+ // already installed (or older). Showing it next to "1.66.39 → 1.66.40"
427
+ // describes a change the user already has (#925). When the release
428
+ // names a version no newer than the installed one AND a better tier
429
+ // exists, take the better tier: the commit tail is sliced at this
430
+ // install's sha, and npm publish times are newer than the install by
431
+ // construction. With nothing better, the stale release still beats an
432
+ // empty dialog.
433
+ const installed = readInstalledVersion(profile, name, activeProfileDir);
434
+ const released = releaseVersionOf(entry.release.tag);
435
+ const behind = installed !== null && released !== null
436
+ && (compareVersions(released, installed) ?? 0) <= 0;
437
+ const hasCommits = (entry.commits?.length ?? 0) > 0;
438
+ if (!(behind && (hasCommits || (specKey === null && npmName !== null)))) {
439
+ return { kind: 'release', release: entry.release };
440
+ }
411
441
  }
412
442
  if (entry?.commits !== undefined && entry.commits.length > 0) {
413
443
  return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) };
package/lib/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
- if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
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;
@@ -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
- wroteNothing: !ok && result.packageResult === undefined,
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) }),
@@ -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
- return bundlePatchTargets(dir)
1314
- .filter(target => target !== name)
1315
- .some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
1315
+ const targets = bundlePatchTargets(dir).filter(target => target !== name);
1316
+ if (targets.some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
1316
1317
  || entryArtifactExists(join(dir, 'node_modules', target))
1317
- || entryArtifactExists(join(workspaceRoot, 'node_modules', target)));
1318
+ || entryArtifactExists(join(workspaceRoot, 'node_modules', target))))
1319
+ return true;
1320
+ // #906: the host's OWN packages live in its install tree, not in any
1321
+ // profile node_modules — `<install>/dsh/node_modules/...`, often inside
1322
+ // app.asar. A carrier whose patch mounts nothing but in-box packages
1323
+ // (@deepseek-ai/dsh-mcp-client and the like) therefore failed every root
1324
+ // above, was reported "broken", and — through validateAddedPlugins — was
1325
+ // uninstalled right after a successful install. `undefined` resolves the
1326
+ // install dir lazily (only carriers pay for it); `null` means "do not look".
1327
+ //
1328
+ // Inside app.asar the probe is blind for EVERY @deepseek-ai/ name (the
1329
+ // same measurement behind #676), so there identity decides instead of the
1330
+ // filesystem — the host publishes that scope, and our own composer loads
1331
+ // those packages from the archive at boot.
1332
+ const host = hostDirectory === undefined ? findDshInstallDir() : hostDirectory;
1333
+ if (host === null || targets.length === 0)
1334
+ return false;
1335
+ const packaged = /[/\\]app\.asar[/\\]/iu.test(host);
1336
+ return targets.some(target => packaged && target.startsWith('@deepseek-ai/')
1337
+ || packageResolvableFrom(join(host, 'package.json'), target));
1338
+ }
1339
+ /** Whether `target` resolves from the host's own dependency tree. */
1340
+ function packageResolvableFrom(anchorPackageJson, target) {
1341
+ try {
1342
+ const paths = createRequire(anchorPackageJson).resolve.paths(target) ?? [];
1343
+ return paths.some(searchPath => existsSync(join(searchPath, target, 'package.json')));
1344
+ }
1345
+ catch {
1346
+ // An unreadable anchor says nothing about the target: treat as absent
1347
+ // rather than loadable, so the caller keeps its conservative answer.
1348
+ return false;
1349
+ }
1318
1350
  }
1319
1351
  /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
1320
1352
  export function pluginSubdirs(root) {
@@ -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
  /**
@@ -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. */
@@ -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.40",
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
- return { kind: 'release', release: entry.release }
467
+ // The release feed is a DAILY snapshot while the update target is read
468
+ // live from npm, so the snapshot's newest release is often the version
469
+ // already installed (or older). Showing it next to "1.66.39 → 1.66.40"
470
+ // describes a change the user already has (#925). When the release
471
+ // names a version no newer than the installed one AND a better tier
472
+ // exists, take the better tier: the commit tail is sliced at this
473
+ // install's sha, and npm publish times are newer than the install by
474
+ // construction. With nothing better, the stale release still beats an
475
+ // empty dialog.
476
+ const installed = readInstalledVersion(profile, name, activeProfileDir)
477
+ const released = releaseVersionOf(entry.release.tag)
478
+ const behind = installed !== null && released !== null
479
+ && (compareVersions(released, installed) ?? 0) <= 0
480
+ const hasCommits = (entry.commits?.length ?? 0) > 0
481
+ if (!(behind && (hasCommits || (specKey === null && npmName !== null)))) {
482
+ return { kind: 'release', release: entry.release }
483
+ }
454
484
  }
455
485
  if (entry?.commits !== undefined && entry.commits.length > 0) {
456
486
  return { kind: 'commits', commits: sliceCommitsAt(entry.commits, current) }
@@ -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 = () => {
@@ -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
- if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
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
@@ -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
- wroteNothing: !ok && result.packageResult === undefined,
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) }),
@@ -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
- return bundlePatchTargets(dir)
1357
- .filter(target => target !== name)
1358
- .some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
1359
- || entryArtifactExists(join(dir, 'node_modules', target))
1360
- || entryArtifactExists(join(workspaceRoot, 'node_modules', target)))
1358
+ const targets = bundlePatchTargets(dir).filter(target => target !== name)
1359
+ if (targets.some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
1360
+ || entryArtifactExists(join(dir, 'node_modules', target))
1361
+ || entryArtifactExists(join(workspaceRoot, 'node_modules', target)))) return true
1362
+ // #906: the host's OWN packages live in its install tree, not in any
1363
+ // profile node_modules — `<install>/dsh/node_modules/...`, often inside
1364
+ // app.asar. A carrier whose patch mounts nothing but in-box packages
1365
+ // (@deepseek-ai/dsh-mcp-client and the like) therefore failed every root
1366
+ // above, was reported "broken", and — through validateAddedPlugins — was
1367
+ // uninstalled right after a successful install. `undefined` resolves the
1368
+ // install dir lazily (only carriers pay for it); `null` means "do not look".
1369
+ //
1370
+ // Inside app.asar the probe is blind for EVERY @deepseek-ai/ name (the
1371
+ // same measurement behind #676), so there identity decides instead of the
1372
+ // filesystem — the host publishes that scope, and our own composer loads
1373
+ // those packages from the archive at boot.
1374
+ const host = hostDirectory === undefined ? findDshInstallDir() : hostDirectory
1375
+ if (host === null || targets.length === 0) return false
1376
+ const packaged = /[/\\]app\.asar[/\\]/iu.test(host)
1377
+ return targets.some(target => packaged && target.startsWith('@deepseek-ai/')
1378
+ || packageResolvableFrom(join(host, 'package.json'), target))
1379
+ }
1380
+
1381
+ /** Whether `target` resolves from the host's own dependency tree. */
1382
+ function packageResolvableFrom(anchorPackageJson: string, target: string): boolean {
1383
+ try {
1384
+ const paths = createRequire(anchorPackageJson).resolve.paths(target) ?? []
1385
+ return paths.some(searchPath => existsSync(join(searchPath, target, 'package.json')))
1386
+ } catch {
1387
+ // An unreadable anchor says nothing about the target: treat as absent
1388
+ // rather than loadable, so the caller keeps its conservative answer.
1389
+ return false
1390
+ }
1361
1391
  }
1362
1392
 
1363
1393
  /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */