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 +5 -0
- package/lib/dsh-cli.js +36 -1
- package/lib/official-desktop.js +36 -1
- package/lib/pnpm-compat.js +15 -0
- package/lib/types/dsh-cli.d.ts +15 -0
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/package.json +1 -1
- package/src/client/MarketSection.tsx +14 -0
- package/src/client/globals.d.ts +18 -0
- package/src/dsh-cli.ts +33 -1
- package/src/official-desktop.ts +38 -1
- package/src/pnpm-compat.ts +16 -1
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)
|
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/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/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.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 = () => {
|
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/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 {
|