dshmarket 1.66.40 → 1.66.41

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/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)
@@ -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 {
@@ -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/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.41",
5
5
  "icon": "assets/logo.svg",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",
@@ -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
@@ -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 {