dshmarket 1.66.39 → 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/README.md +1 -1
- package/README.zh.md +1 -1
- package/assets/logo.svg +19 -0
- package/client/client.js +7 -2
- package/lib/dsh-cli.js +36 -1
- package/lib/install.js +125 -7
- package/lib/official-desktop.js +52 -3
- package/lib/pnpm-compat.js +15 -0
- package/lib/routes.js +25 -3
- package/lib/types/dsh-cli.d.ts +25 -0
- package/lib/types/install.d.ts +28 -0
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/lib/update-api-v1.js +21 -1
- package/package.json +3 -1
- package/src/client/MarketSection.tsx +14 -0
- package/src/client/globals.d.ts +18 -0
- package/src/client/locales.ts +2 -2
- package/src/dsh-cli.ts +43 -1
- package/src/install.ts +115 -7
- package/src/official-desktop.ts +54 -3
- package/src/pnpm-compat.ts +16 -1
- package/src/routes.ts +25 -3
- package/src/update-api-v1.ts +22 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ English | [中文](README.zh.md)
|
|
|
11
11
|
|
|
12
12
|
The plugin market inside DeepSeek Harness. Open Settings → **Plugin Market** → browse, search, one-click install. Settings sits in the sidebar up to dsh 0.2.0 and in the account menu from 0.2.1.
|
|
13
13
|
|
|
14
|
-
> 📡 **The same catalog is also a plugin market index.** On dsh 0.2.1+, open **Plugins → Discover → Index sources** and add `https://awesome-dsh-plugin.com/index.json`
|
|
14
|
+
> 📡 **The same catalog is also a plugin market index.** On dsh 0.2.1+, open **Plugins → Discover → Index sources** and add `https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json`. That is this list as a format-1 index, served from npm so it loads quickly everywhere — dsh gives a source 15 seconds, and the GitHub Pages copies (`https://awesome-dsh-plugin.com/index.json` and `https://dshmarket.com/index.json`) can exceed that from some networks. The host's Discover tab and the `plugin_market` agent tool then search it.
|
|
15
15
|
|
|
16
16
|

|
|
17
17
|
|
package/README.zh.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
装在 DeepSeek Harness 里的插件市场。打开设置 → **插件市场** → 逛一逛,点一下,装好。设置入口在 dsh 0.2.0 及以前位于侧边栏,0.2.1 起移到了账号菜单里。
|
|
13
13
|
|
|
14
|
-
> 📡 **同一份目录也是一份插件市场索引。** dsh 0.2.1 起,打开 **插件 → 发现(Discover)→ 索引源(Index sources)**,添加 `https://awesome-dsh-plugin.com/index.json
|
|
14
|
+
> 📡 **同一份目录也是一份插件市场索引。** dsh 0.2.1 起,打开 **插件 → 发现(Discover)→ 索引源(Index sources)**,添加 `https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json`。它就是这份列表的 format 1 索引,放在 npm 上分发,所以在哪里都快——dsh 给索引源的时间只有 15 秒,而 GitHub Pages 上的那两份(`https://awesome-dsh-plugin.com/index.json` 与 `https://dshmarket.com/index.json`)在部分网络下会超过。宿主自己的发现页和 `plugin_market` 智能体工具就能搜索它。
|
|
15
15
|
|
|
16
16
|

|
|
17
17
|
|
package/assets/logo.svg
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-labelledby="t">
|
|
2
|
+
<title id="t">dsh-market</title>
|
|
3
|
+
<!-- Shared brand mark with awesome-dsh-plugin: the block grid with one
|
|
4
|
+
cinnabar block being plugged in. -->
|
|
5
|
+
<rect width="128" height="128" rx="28" fill="#f6f2ea"/>
|
|
6
|
+
<g transform="translate(15.7 16.7) scale(0.3) translate(-112 -78)">
|
|
7
|
+
<g fill="#2b2620">
|
|
8
|
+
<rect x="112" y="112" width="88" height="88" rx="14"/>
|
|
9
|
+
<rect x="212" y="112" width="88" height="88" rx="14"/>
|
|
10
|
+
<rect x="112" y="212" width="88" height="88" rx="14"/>
|
|
11
|
+
<rect x="212" y="212" width="88" height="88" rx="14"/>
|
|
12
|
+
<rect x="112" y="312" width="88" height="88" rx="14"/>
|
|
13
|
+
<rect x="212" y="312" width="88" height="88" rx="14"/>
|
|
14
|
+
<rect x="312" y="212" width="88" height="88" rx="14"/>
|
|
15
|
+
<rect x="312" y="312" width="88" height="88" rx="14"/>
|
|
16
|
+
</g>
|
|
17
|
+
<rect x="346" y="78" width="88" height="88" rx="14" fill="#c0392b" transform="rotate(9 390 122)"/>
|
|
18
|
+
</g>
|
|
19
|
+
</svg>
|
package/client/client.js
CHANGED
|
@@ -211,7 +211,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
211
211
|
setSidebarHint: "在侧栏「插件」下方显示市场。设置里的入口不变。",
|
|
212
212
|
setSidebarOnlyDoorHint: "这个宿主没有市场设置页,这一行就是进入市场的入口。",
|
|
213
213
|
setIndexSource: "官方 Discover 索引",
|
|
214
|
-
setIndexSourceHint: "dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://
|
|
214
|
+
setIndexSourceHint: "dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json —— 官方发现页和 plugin_market 智能体工具会搜索同一份目录。",
|
|
215
215
|
setSidebarOn: "显示",
|
|
216
216
|
setSidebarOff: "不显示",
|
|
217
217
|
sidebarTip: "想更快打开?可在侧栏显示。",
|
|
@@ -951,7 +951,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
951
951
|
setSidebarHint: "Shows the market below Plugins in the sidebar. The Settings entry stays.",
|
|
952
952
|
setSidebarOnlyDoorHint: "This host keeps the market out of Settings, so this row is the way in.",
|
|
953
953
|
setIndexSource: "Discover index",
|
|
954
|
-
setIndexSourceHint: "On dsh 0.2.1 and newer, add https://
|
|
954
|
+
setIndexSourceHint: "On dsh 0.2.1 and newer, add https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json under Plugins → Discover → Index sources — the official Discover tab and the plugin_market agent tool then search the same catalog.",
|
|
955
955
|
setSidebarOn: "Show",
|
|
956
956
|
setSidebarOff: "Hide",
|
|
957
957
|
sidebarTip: "Want a quicker open? Show it in the sidebar.",
|
|
@@ -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/install.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* update staleness detection. Every function takes the plugin runner as a
|
|
5
5
|
* parameter so tests can substitute a recording fake.
|
|
6
6
|
*/
|
|
7
|
-
import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs';
|
|
7
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs';
|
|
8
8
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
9
9
|
import { findDshInstallDir } from './dsh-install.js';
|
|
10
10
|
import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
|
|
@@ -331,12 +331,32 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
331
331
|
//
|
|
332
332
|
// The wait on a fresh release is the one of these where "no retry" leaves
|
|
333
333
|
// the user with nothing to do at all: the plugin IS published, pnpm just
|
|
334
|
-
// will not take it yet
|
|
335
|
-
//
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
334
|
+
// will not take it yet. #852 pointed at plain `dsh web`, but the desktop
|
|
335
|
+
// profile is host-managed — that door is shut (#909). Say the wait, and
|
|
336
|
+
// when pnpm named the publish time, how long is left (#919).
|
|
337
|
+
//
|
|
338
|
+
// Drop the classifier's "market already retried" sentence first: it was
|
|
339
|
+
// appended above and is false on this host, and the client prefers
|
|
340
|
+
// body.error / that classified half over a note tacked on afterwards.
|
|
341
|
+
let note;
|
|
342
|
+
if (unavailableOption === RELEASE_AGE_OVERRIDE) {
|
|
343
|
+
const classified = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode);
|
|
344
|
+
if (classified?.code === 'release-age-violation') {
|
|
345
|
+
const cut = result.stderr.lastIndexOf(classified.message);
|
|
346
|
+
if (cut !== -1) {
|
|
347
|
+
result = { ...result, stderr: result.stderr.slice(0, cut).trimEnd() };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const windowMs = readMinimumReleaseAgeMs(profile, profileDirectory);
|
|
351
|
+
const remaining = windowMs === null
|
|
352
|
+
? null
|
|
353
|
+
: remainingReleaseAgeWait(`${result.stderr}\n${result.stdout}`, Date.now(), windowMs);
|
|
354
|
+
const wait = remaining === null ? { zh: '', en: '' } : formatRemainingReleaseAgeWait(remaining);
|
|
355
|
+
note = `这个插件的新版本比本机的新版本等待期还新,所以现在装不了(改别的插件也会被拦住)。官方桌面端不接受临时放行,所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**${wait.zh}。过了等待期后再在市场点一次更新。 / The plugin's new version is younger than this machine's fresh-release wait, so the install was refused (a change to another plugin too). The official desktop app takes no one-shot bypass, so no automatic retry happens here. **Update again once the version is past the wait**${wait.en}. Try the market update again after the wait.`;
|
|
356
|
+
}
|
|
357
|
+
else {
|
|
358
|
+
note = `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`;
|
|
359
|
+
}
|
|
340
360
|
result = { ...result, stderr: `${result.stderr}\n\n${note}` };
|
|
341
361
|
}
|
|
342
362
|
// pnpm can append a second rule even on success (#883, #878), including
|
|
@@ -419,6 +439,104 @@ const DIAGNOSTICS_TAIL_BYTES = 8192;
|
|
|
419
439
|
export function pnpmNeverStarted(result) {
|
|
420
440
|
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'pnpm-unusable';
|
|
421
441
|
}
|
|
442
|
+
/**
|
|
443
|
+
* Whether pnpm refused a too-young release without writing to the profile.
|
|
444
|
+
*
|
|
445
|
+
* Same nature as wroteNothing / pnpmNeverStarted for the update route's
|
|
446
|
+
* rollback (#919): pnpm DID start, read the lockfile, then exited 1 on
|
|
447
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION (or the sibling mature-match /
|
|
448
|
+
* resolution-policy shapes). Nothing was installed or removed, so restoring
|
|
449
|
+
* "the previous build" is alarm about an untouched profile — and on the
|
|
450
|
+
* official desktop bridge it pairs every wait with a false
|
|
451
|
+
* "restoration could not be verified" line that covers the real reason.
|
|
452
|
+
* @param result - the failed run.
|
|
453
|
+
* @returns true when the failure is pnpm's fresh-release gate.
|
|
454
|
+
*/
|
|
455
|
+
export function pnpmRefusedYoungRelease(result) {
|
|
456
|
+
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'release-age-violation';
|
|
457
|
+
}
|
|
458
|
+
/** pnpm's default minimumReleaseAge window (1440 minutes). */
|
|
459
|
+
const DEFAULT_RELEASE_AGE_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
460
|
+
/**
|
|
461
|
+
* The profile's fresh-release wait window in ms (#919).
|
|
462
|
+
*
|
|
463
|
+
* Reads `minimumReleaseAge` (minutes) from pnpm-workspace.yaml, then
|
|
464
|
+
* `minimum-release-age` from `.npmrc`. A key that is present but not a
|
|
465
|
+
* number returns null — callers must not invent a remaining wait. When
|
|
466
|
+
* neither file names the key, pnpm's default 1440 minutes applies.
|
|
467
|
+
*/
|
|
468
|
+
export function readMinimumReleaseAgeMs(profile, explicitDir) {
|
|
469
|
+
const dir = profileDir(profile, explicitDir);
|
|
470
|
+
const fromWorkspace = readReleaseAgeMinutesFromWorkspace(dir);
|
|
471
|
+
if (fromWorkspace === 'invalid')
|
|
472
|
+
return null;
|
|
473
|
+
if (typeof fromWorkspace === 'number')
|
|
474
|
+
return fromWorkspace * 60 * 1000;
|
|
475
|
+
const fromNpmrc = readReleaseAgeMinutesFromNpmrc(dir);
|
|
476
|
+
if (fromNpmrc === 'invalid')
|
|
477
|
+
return null;
|
|
478
|
+
if (typeof fromNpmrc === 'number')
|
|
479
|
+
return fromNpmrc * 60 * 1000;
|
|
480
|
+
return DEFAULT_RELEASE_AGE_WINDOW_MS;
|
|
481
|
+
}
|
|
482
|
+
/** @returns minutes, `'invalid'` when the key is present but unreadable, or null when absent. */
|
|
483
|
+
function readReleaseAgeMinutesFromWorkspace(dir) {
|
|
484
|
+
let yaml;
|
|
485
|
+
try {
|
|
486
|
+
yaml = readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8');
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
if (!/^minimumReleaseAge\s*:/m.test(yaml))
|
|
492
|
+
return null;
|
|
493
|
+
const match = /^minimumReleaseAge:\s*(\d+)\s*(?:#.*)?$/m.exec(yaml);
|
|
494
|
+
if (match === null)
|
|
495
|
+
return 'invalid';
|
|
496
|
+
const minutes = Number(match[1]);
|
|
497
|
+
return Number.isFinite(minutes) ? minutes : 'invalid';
|
|
498
|
+
}
|
|
499
|
+
/** @returns minutes, `'invalid'` when the key is present but unreadable, or null when absent. */
|
|
500
|
+
function readReleaseAgeMinutesFromNpmrc(dir) {
|
|
501
|
+
let npmrc;
|
|
502
|
+
try {
|
|
503
|
+
npmrc = readFileSync(join(dir, '.npmrc'), 'utf8');
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
if (!/^minimum-release-age\s*=/m.test(npmrc))
|
|
509
|
+
return null;
|
|
510
|
+
const match = /^minimum-release-age\s*=\s*(\d+)\s*(?:#.*)?$/m.exec(npmrc);
|
|
511
|
+
if (match === null)
|
|
512
|
+
return 'invalid';
|
|
513
|
+
const minutes = Number(match[1]);
|
|
514
|
+
return Number.isFinite(minutes) ? minutes : 'invalid';
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* How long is left in the fresh-release wait, when pnpm named the publish
|
|
518
|
+
* time in its refusal (#919). Null when the output has no usable timestamp.
|
|
519
|
+
*/
|
|
520
|
+
export function remainingReleaseAgeWait(output, now = Date.now(), windowMs = DEFAULT_RELEASE_AGE_WINDOW_MS) {
|
|
521
|
+
const match = /published at (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i.exec(output);
|
|
522
|
+
if (match === null)
|
|
523
|
+
return null;
|
|
524
|
+
const published = Date.parse(match[1]);
|
|
525
|
+
if (!Number.isFinite(published))
|
|
526
|
+
return null;
|
|
527
|
+
return Math.max(0, published + windowMs - now);
|
|
528
|
+
}
|
|
529
|
+
/** Human remaining-wait fragment for the desktop release-age note. */
|
|
530
|
+
function formatRemainingReleaseAgeWait(remainingMs) {
|
|
531
|
+
if (remainingMs <= 0) {
|
|
532
|
+
return { zh: '(现在再试一次即可)', en: ' (try again now)' };
|
|
533
|
+
}
|
|
534
|
+
const hours = Math.max(1, Math.ceil(remainingMs / (60 * 60 * 1000)));
|
|
535
|
+
return {
|
|
536
|
+
zh: `(大约还剩 ${hours} 小时)`,
|
|
537
|
+
en: ` (about ${hours} hour${hours === 1 ? '' : 's'} left)`,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
422
540
|
/**
|
|
423
541
|
* Whether pnpm failed because the running host holds the package's files
|
|
424
542
|
* open, so nothing pnpm runs from inside that host can replace them.
|
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
|
/**
|
|
@@ -82,7 +115,14 @@ function selfUpdateMessage(spec) {
|
|
|
82
115
|
const at = spec.indexOf('@', 1);
|
|
83
116
|
const version = at === -1 ? '' : spec.slice(at + 1);
|
|
84
117
|
const target = version === '' ? spec : `${registryNameOf(spec) ?? spec}@${version}`;
|
|
85
|
-
|
|
118
|
+
// Two paths that used to be named here do not exist on dsh 0.2.1, and a
|
|
119
|
+
// reporter proved both (#909): `dsh plugin --profile desktop` is refused
|
|
120
|
+
// outright, and the app's Settings → Plugins has no update entry — its
|
|
121
|
+
// manager refuses re-adding a package that is already declared. The only
|
|
122
|
+
// route left is pnpm run against the profile with the app fully quit, which
|
|
123
|
+
// is what the reporter verified. `--config.minimum-release-age=0` is the
|
|
124
|
+
// market's own one-shot flag, spelled so it works in every shell.
|
|
125
|
+
return `市场不能在自己的请求里更新自己:桌面端的移除会先把市场从正在运行的组合里摘掉,补装那一步就执行不到了(#900),而应用内的「设置 → 插件」没有更新入口(#909)。要更新${version === '' ? '' : ` 到 ${version}`}:完全退出 DSH Desktop,在 profile 目录执行 \`pnpm add ${target} --config.minimum-release-age=0\`(没有 pnpm 时用 \`npx pnpm add …\`),然后重新启动。Windows 的 profile 一般在 %USERPROFILE%\\.dsh\\profiles\\desktop。 / The market cannot update itself: the desktop app unloads it from the running composition first, so the reinstall never runs (#900), and the app's Settings → Plugins has no update entry (#909). To update${version === '' ? '' : ` to ${version}`}: quit DSH Desktop completely, run \`pnpm add ${target} --config.minimum-release-age=0\` in the profile directory (use \`npx pnpm add …\` if pnpm is not installed), then start DSH again. On Windows the profile is usually %USERPROFILE%\\.dsh\\profiles\\desktop.`;
|
|
86
126
|
}
|
|
87
127
|
/** The manager's own refusal code, whether it was returned or thrown. */
|
|
88
128
|
function managementCode(error) {
|
|
@@ -243,12 +283,21 @@ _profileDirectory) {
|
|
|
243
283
|
cancelled: current.cancelled || result.application === 'cancelled',
|
|
244
284
|
// No package result means the manager stopped before it ran pnpm: the
|
|
245
285
|
// profile is untouched, and the route must not try to "restore" it.
|
|
246
|
-
|
|
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)),
|
|
289
|
+
// The manager's own code, kept machine-readable (#909): a client can
|
|
290
|
+
// tell a self-update refusal from an ordinary failure.
|
|
291
|
+
...(ok ? {} : { refusalCode: managementCode(result.error) }),
|
|
247
292
|
};
|
|
248
293
|
}, (error) => {
|
|
249
294
|
const message = detail(error);
|
|
250
295
|
progress.error = message;
|
|
251
|
-
return {
|
|
296
|
+
return {
|
|
297
|
+
...failure(message, 127),
|
|
298
|
+
wroteNothing: managementCode(error) !== undefined,
|
|
299
|
+
...(managementCode(error) === undefined ? {} : { refusalCode: managementCode(error) }),
|
|
300
|
+
};
|
|
252
301
|
}).finally(() => {
|
|
253
302
|
progress.active = false;
|
|
254
303
|
progress.cancelling = false;
|
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/routes.js
CHANGED
|
@@ -32,7 +32,7 @@ import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnaps
|
|
|
32
32
|
import { readHostRecoveryView, restoreHostRecoveryBackup } from './host-recovery.js';
|
|
33
33
|
import { trialValidate } from './trial.js';
|
|
34
34
|
import { catalogRepoKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedAllowBuildsKeys, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.js';
|
|
35
|
-
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
35
|
+
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, pnpmRefusedYoungRelease, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
|
|
36
36
|
import { classifyPnpmFailure } from './pnpm-compat.js';
|
|
37
37
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
|
|
38
38
|
import { asRegion, githubProxyManaged, normalizeGithubProxy, REGIONS, routesFor, setActiveRegion, setCustomGithubProxy, } from './regions.js';
|
|
@@ -333,6 +333,14 @@ function pnpmFailureMessage(result) {
|
|
|
333
333
|
return undefined;
|
|
334
334
|
if (failure.code === 'ignored-builds' || failure.code === 'git-prepare-not-allowed')
|
|
335
335
|
return undefined;
|
|
336
|
+
// Desktop release-age note is already the actionable sentence (#919). Do
|
|
337
|
+
// not cover it with the classifier's "market already retried" claim —
|
|
338
|
+
// install/uninstall fall through to stderr via failureText when error is
|
|
339
|
+
// undefined.
|
|
340
|
+
if (failure.code === 'release-age-violation'
|
|
341
|
+
&& /没有自动重试|no automatic retry happens here/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
336
344
|
return failure.message;
|
|
337
345
|
}
|
|
338
346
|
/**
|
|
@@ -4606,7 +4614,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4606
4614
|
// verify restoration, inspect this profile" warning this block
|
|
4607
4615
|
// raises on a failed rollback would be alarm about an untouched
|
|
4608
4616
|
// profile.
|
|
4609
|
-
&& result.wroteNothing !== true
|
|
4617
|
+
&& result.wroteNothing !== true
|
|
4618
|
+
// pnpm ran, then refused a too-young release without writing
|
|
4619
|
+
// (#919). Same untouched profile; skipping the rollback keeps
|
|
4620
|
+
// the release-age reason visible instead of covering it with a
|
|
4621
|
+
// false "restoration could not be verified" alarm.
|
|
4622
|
+
&& !pnpmRefusedYoungRelease(result)) {
|
|
4610
4623
|
if (pnpmBlockedByOpenFiles(result)) {
|
|
4611
4624
|
const kept = keepLockedBuild();
|
|
4612
4625
|
rollbackOk = kept.ok;
|
|
@@ -4943,7 +4956,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4943
4956
|
// ever sees this when something really is unbootable.
|
|
4944
4957
|
...(() => { const orphans = orphanBundles(); return orphans.length > 0 ? { orphanBundles: orphans } : {}; })(),
|
|
4945
4958
|
staleReason: staleReason ?? undefined,
|
|
4946
|
-
|
|
4959
|
+
// The host's own refusal code when it stopped before pnpm
|
|
4960
|
+
// (#909): `market-self-update` tells a client that the button
|
|
4961
|
+
// cannot work on this host, not that the update was broken.
|
|
4962
|
+
failureCode: versionFailureCode ?? result.refusalCode ?? undefined,
|
|
4947
4963
|
renamedTo: renamedTo ?? undefined,
|
|
4948
4964
|
removedDeclaration: removedDeclaration ?? undefined,
|
|
4949
4965
|
...(droppedBundles.length > 0 ? { droppedBundles } : {}),
|
|
@@ -4954,8 +4970,14 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4954
4970
|
// before running pnpm there is no classifier for its words and
|
|
4955
4971
|
// no rollback behind it, so without this the answer carried no
|
|
4956
4972
|
// reason at all.
|
|
4973
|
+
// Release-age on Desktop (#919): withHoistRecovery already wrote
|
|
4974
|
+
// the wait note into stderr (and stripped the false "already
|
|
4975
|
+
// retried" classifier). Prefer that over pnpmFailureMessage,
|
|
4976
|
+
// which would re-surface the classifier alone — the client
|
|
4977
|
+
// shows body.error first.
|
|
4957
4978
|
error: versionFailureError ?? renamedError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError
|
|
4958
4979
|
?? (result.wroteNothing === true && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
|
|
4980
|
+
?? (pnpmRefusedYoungRelease(result) && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
|
|
4959
4981
|
?? pnpmFailureMessage(result) ?? undefined,
|
|
4960
4982
|
exitCode: result.exitCode,
|
|
4961
4983
|
timedOut: result.timedOut,
|
package/lib/types/dsh-cli.d.ts
CHANGED
|
@@ -172,6 +172,16 @@ export interface InstallResult {
|
|
|
172
172
|
* (#897) — and their only true account is "nothing happened".
|
|
173
173
|
*/
|
|
174
174
|
wroteNothing?: boolean;
|
|
175
|
+
/**
|
|
176
|
+
* The host's own refusal code, when it stopped before running pnpm.
|
|
177
|
+
*
|
|
178
|
+
* The desktop manager answers with structured codes — `already-installed`,
|
|
179
|
+
* `bundle-in-use`, `market-self-update` — but they used to survive only as
|
|
180
|
+
* text inside `stderr`. The update route forwards this to `failureCode`,
|
|
181
|
+
* so a client can tell "the market cannot update itself here" (#909) from
|
|
182
|
+
* an ordinary failure without parsing a sentence.
|
|
183
|
+
*/
|
|
184
|
+
refusalCode?: string;
|
|
175
185
|
/** Package names pnpm reported as having ignored build scripts (ndjson). */
|
|
176
186
|
ignoredBuilds?: string[];
|
|
177
187
|
/**
|
|
@@ -289,6 +299,21 @@ export interface DesktopPluginRuntime extends PluginCommandRuntime {
|
|
|
289
299
|
* (Contributed in #7 by @mraing.)
|
|
290
300
|
*/
|
|
291
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;
|
|
292
317
|
/**
|
|
293
318
|
* Cancel the plugin command currently running.
|
|
294
319
|
* @returns true when there was one to cancel.
|
package/lib/types/install.d.ts
CHANGED
|
@@ -147,6 +147,34 @@ export declare function diagnosticsTail(result: {
|
|
|
147
147
|
* @returns true when the failure happened before pnpm could run.
|
|
148
148
|
*/
|
|
149
149
|
export declare function pnpmNeverStarted(result: InstallResult): boolean;
|
|
150
|
+
/**
|
|
151
|
+
* Whether pnpm refused a too-young release without writing to the profile.
|
|
152
|
+
*
|
|
153
|
+
* Same nature as wroteNothing / pnpmNeverStarted for the update route's
|
|
154
|
+
* rollback (#919): pnpm DID start, read the lockfile, then exited 1 on
|
|
155
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION (or the sibling mature-match /
|
|
156
|
+
* resolution-policy shapes). Nothing was installed or removed, so restoring
|
|
157
|
+
* "the previous build" is alarm about an untouched profile — and on the
|
|
158
|
+
* official desktop bridge it pairs every wait with a false
|
|
159
|
+
* "restoration could not be verified" line that covers the real reason.
|
|
160
|
+
* @param result - the failed run.
|
|
161
|
+
* @returns true when the failure is pnpm's fresh-release gate.
|
|
162
|
+
*/
|
|
163
|
+
export declare function pnpmRefusedYoungRelease(result: InstallResult): boolean;
|
|
164
|
+
/**
|
|
165
|
+
* The profile's fresh-release wait window in ms (#919).
|
|
166
|
+
*
|
|
167
|
+
* Reads `minimumReleaseAge` (minutes) from pnpm-workspace.yaml, then
|
|
168
|
+
* `minimum-release-age` from `.npmrc`. A key that is present but not a
|
|
169
|
+
* number returns null — callers must not invent a remaining wait. When
|
|
170
|
+
* neither file names the key, pnpm's default 1440 minutes applies.
|
|
171
|
+
*/
|
|
172
|
+
export declare function readMinimumReleaseAgeMs(profile: string, explicitDir?: string): number | null;
|
|
173
|
+
/**
|
|
174
|
+
* How long is left in the fresh-release wait, when pnpm named the publish
|
|
175
|
+
* time in its refusal (#919). Null when the output has no usable timestamp.
|
|
176
|
+
*/
|
|
177
|
+
export declare function remainingReleaseAgeWait(output: string, now?: number, windowMs?: number): number | null;
|
|
150
178
|
/**
|
|
151
179
|
* Whether pnpm failed because the running host holds the package's files
|
|
152
180
|
* open, so nothing pnpm runs from inside that host can replace them.
|
|
@@ -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/update-api-v1.js
CHANGED
|
@@ -18,9 +18,29 @@ function text(value) {
|
|
|
18
18
|
function bool(value) {
|
|
19
19
|
return value === true;
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Host refusal codes that may travel through `failureCode` unchanged.
|
|
23
|
+
*
|
|
24
|
+
* The route forwards the desktop manager's own codes (#909); the v1 API only
|
|
25
|
+
* re-publishes the ones a caller can act on, so an internal code cannot leak
|
|
26
|
+
* by accident. `market-self-update` is the one this exists for: the market
|
|
27
|
+
* cannot update itself on a host whose manager refuses re-adds, and a caller
|
|
28
|
+
* should be able to say so instead of retrying.
|
|
29
|
+
*/
|
|
30
|
+
const FORWARDED_FAILURE_CODES = new Set([
|
|
31
|
+
'DOWNGRADE_DETECTED',
|
|
32
|
+
'RESOLVED_VERSION_MISMATCH',
|
|
33
|
+
'market-self-update',
|
|
34
|
+
'already-installed',
|
|
35
|
+
'bundle-in-use',
|
|
36
|
+
'not-removable',
|
|
37
|
+
'stop-profile',
|
|
38
|
+
'management-required',
|
|
39
|
+
'incompatible-version',
|
|
40
|
+
]);
|
|
21
41
|
function failureCode(status, body) {
|
|
22
42
|
const explicit = text(body.failureCode);
|
|
23
|
-
if (explicit
|
|
43
|
+
if (explicit !== null && FORWARDED_FAILURE_CODES.has(explicit))
|
|
24
44
|
return explicit;
|
|
25
45
|
if (body.agentsBusy === true)
|
|
26
46
|
return 'AGENTS_RUNNING';
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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
|
+
"icon": "assets/logo.svg",
|
|
5
6
|
"type": "module",
|
|
6
7
|
"main": "lib/index.js",
|
|
7
8
|
"types": "lib/types/index.d.ts",
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
"./locale/*.json": "./locale/*.json"
|
|
73
74
|
},
|
|
74
75
|
"files": [
|
|
76
|
+
"assets/logo.svg",
|
|
75
77
|
"locale",
|
|
76
78
|
"lib",
|
|
77
79
|
"src",
|
|
@@ -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/client/locales.ts
CHANGED
|
@@ -14,7 +14,7 @@ export const zh = {
|
|
|
14
14
|
setSidebarHint: '在侧栏「插件」下方显示市场。设置里的入口不变。',
|
|
15
15
|
setSidebarOnlyDoorHint: '这个宿主没有市场设置页,这一行就是进入市场的入口。',
|
|
16
16
|
setIndexSource: '官方 Discover 索引',
|
|
17
|
-
setIndexSourceHint: 'dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://
|
|
17
|
+
setIndexSourceHint: 'dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json —— 官方发现页和 plugin_market 智能体工具会搜索同一份目录。',
|
|
18
18
|
setSidebarOn: '显示',
|
|
19
19
|
setSidebarOff: '不显示',
|
|
20
20
|
sidebarTip: '想更快打开?可在侧栏显示。',
|
|
@@ -778,7 +778,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
778
778
|
setSidebarHint: 'Shows the market below Plugins in the sidebar. The Settings entry stays.',
|
|
779
779
|
setSidebarOnlyDoorHint: 'This host keeps the market out of Settings, so this row is the way in.',
|
|
780
780
|
setIndexSource: 'Discover index',
|
|
781
|
-
setIndexSourceHint: 'On dsh 0.2.1 and newer, add https://
|
|
781
|
+
setIndexSourceHint: 'On dsh 0.2.1 and newer, add https://cdn.jsdelivr.net/npm/dsh-plugin-catalog/index.json under Plugins → Discover → Index sources — the official Discover tab and the plugin_market agent tool then search the same catalog.',
|
|
782
782
|
setSidebarOn: 'Show',
|
|
783
783
|
setSidebarOff: 'Hide',
|
|
784
784
|
sidebarTip: 'Want a quicker open? Show it in the sidebar.',
|
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'
|
|
@@ -511,6 +511,16 @@ export interface InstallResult {
|
|
|
511
511
|
* (#897) — and their only true account is "nothing happened".
|
|
512
512
|
*/
|
|
513
513
|
wroteNothing?: boolean
|
|
514
|
+
/**
|
|
515
|
+
* The host's own refusal code, when it stopped before running pnpm.
|
|
516
|
+
*
|
|
517
|
+
* The desktop manager answers with structured codes — `already-installed`,
|
|
518
|
+
* `bundle-in-use`, `market-self-update` — but they used to survive only as
|
|
519
|
+
* text inside `stderr`. The update route forwards this to `failureCode`,
|
|
520
|
+
* so a client can tell "the market cannot update itself here" (#909) from
|
|
521
|
+
* an ordinary failure without parsing a sentence.
|
|
522
|
+
*/
|
|
523
|
+
refusalCode?: string
|
|
514
524
|
/** Package names pnpm reported as having ignored build scripts (ndjson). */
|
|
515
525
|
ignoredBuilds?: string[]
|
|
516
526
|
/**
|
|
@@ -713,6 +723,35 @@ export function killChild(child: ChildProcess): void {
|
|
|
713
723
|
let activeChild: ChildProcess | null = null
|
|
714
724
|
let cancelRequested = false
|
|
715
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
|
+
|
|
716
755
|
interface ActiveDesktopOperation {
|
|
717
756
|
readonly owner: symbol
|
|
718
757
|
readonly cancel: () => void
|
|
@@ -1267,6 +1306,9 @@ export function runDshPlugin(profile: string, pluginArgs: string[]): Promise<Ins
|
|
|
1267
1306
|
})
|
|
1268
1307
|
child.on('close', (code) => {
|
|
1269
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)
|
|
1270
1312
|
progress.active = false
|
|
1271
1313
|
progress.cancelling = false
|
|
1272
1314
|
if (activeChild === child) activeChild = null
|
package/src/install.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* parameter so tests can substitute a recording fake.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs'
|
|
8
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs'
|
|
9
9
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
10
10
|
import type { InstallResult, PluginRunner } from './dsh-cli.ts'
|
|
11
11
|
import { findDshInstallDir } from './dsh-install.ts'
|
|
@@ -337,12 +337,31 @@ export async function withHoistRecovery(
|
|
|
337
337
|
//
|
|
338
338
|
// The wait on a fresh release is the one of these where "no retry" leaves
|
|
339
339
|
// the user with nothing to do at all: the plugin IS published, pnpm just
|
|
340
|
-
// will not take it yet
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
340
|
+
// will not take it yet. #852 pointed at plain `dsh web`, but the desktop
|
|
341
|
+
// profile is host-managed — that door is shut (#909). Say the wait, and
|
|
342
|
+
// when pnpm named the publish time, how long is left (#919).
|
|
343
|
+
//
|
|
344
|
+
// Drop the classifier's "market already retried" sentence first: it was
|
|
345
|
+
// appended above and is false on this host, and the client prefers
|
|
346
|
+
// body.error / that classified half over a note tacked on afterwards.
|
|
347
|
+
let note: string
|
|
348
|
+
if (unavailableOption === RELEASE_AGE_OVERRIDE) {
|
|
349
|
+
const classified = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)
|
|
350
|
+
if (classified?.code === 'release-age-violation') {
|
|
351
|
+
const cut = result.stderr.lastIndexOf(classified.message)
|
|
352
|
+
if (cut !== -1) {
|
|
353
|
+
result = { ...result, stderr: result.stderr.slice(0, cut).trimEnd() }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const windowMs = readMinimumReleaseAgeMs(profile, profileDirectory)
|
|
357
|
+
const remaining = windowMs === null
|
|
358
|
+
? null
|
|
359
|
+
: remainingReleaseAgeWait(`${result.stderr}\n${result.stdout}`, Date.now(), windowMs)
|
|
360
|
+
const wait = remaining === null ? { zh: '', en: '' } : formatRemainingReleaseAgeWait(remaining)
|
|
361
|
+
note = `这个插件的新版本比本机的新版本等待期还新,所以现在装不了(改别的插件也会被拦住)。官方桌面端不接受临时放行,所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**${wait.zh}。过了等待期后再在市场点一次更新。 / The plugin's new version is younger than this machine's fresh-release wait, so the install was refused (a change to another plugin too). The official desktop app takes no one-shot bypass, so no automatic retry happens here. **Update again once the version is past the wait**${wait.en}. Try the market update again after the wait.`
|
|
362
|
+
} else {
|
|
363
|
+
note = `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`
|
|
364
|
+
}
|
|
346
365
|
result = { ...result, stderr: `${result.stderr}\n\n${note}` }
|
|
347
366
|
}
|
|
348
367
|
// pnpm can append a second rule even on success (#883, #878), including
|
|
@@ -423,6 +442,95 @@ export function pnpmNeverStarted(result: InstallResult): boolean {
|
|
|
423
442
|
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'pnpm-unusable'
|
|
424
443
|
}
|
|
425
444
|
|
|
445
|
+
/**
|
|
446
|
+
* Whether pnpm refused a too-young release without writing to the profile.
|
|
447
|
+
*
|
|
448
|
+
* Same nature as wroteNothing / pnpmNeverStarted for the update route's
|
|
449
|
+
* rollback (#919): pnpm DID start, read the lockfile, then exited 1 on
|
|
450
|
+
* ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION (or the sibling mature-match /
|
|
451
|
+
* resolution-policy shapes). Nothing was installed or removed, so restoring
|
|
452
|
+
* "the previous build" is alarm about an untouched profile — and on the
|
|
453
|
+
* official desktop bridge it pairs every wait with a false
|
|
454
|
+
* "restoration could not be verified" line that covers the real reason.
|
|
455
|
+
* @param result - the failed run.
|
|
456
|
+
* @returns true when the failure is pnpm's fresh-release gate.
|
|
457
|
+
*/
|
|
458
|
+
export function pnpmRefusedYoungRelease(result: InstallResult): boolean {
|
|
459
|
+
return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'release-age-violation'
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** pnpm's default minimumReleaseAge window (1440 minutes). */
|
|
463
|
+
const DEFAULT_RELEASE_AGE_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* The profile's fresh-release wait window in ms (#919).
|
|
467
|
+
*
|
|
468
|
+
* Reads `minimumReleaseAge` (minutes) from pnpm-workspace.yaml, then
|
|
469
|
+
* `minimum-release-age` from `.npmrc`. A key that is present but not a
|
|
470
|
+
* number returns null — callers must not invent a remaining wait. When
|
|
471
|
+
* neither file names the key, pnpm's default 1440 minutes applies.
|
|
472
|
+
*/
|
|
473
|
+
export function readMinimumReleaseAgeMs(profile: string, explicitDir?: string): number | null {
|
|
474
|
+
const dir = profileDir(profile, explicitDir)
|
|
475
|
+
const fromWorkspace = readReleaseAgeMinutesFromWorkspace(dir)
|
|
476
|
+
if (fromWorkspace === 'invalid') return null
|
|
477
|
+
if (typeof fromWorkspace === 'number') return fromWorkspace * 60 * 1000
|
|
478
|
+
const fromNpmrc = readReleaseAgeMinutesFromNpmrc(dir)
|
|
479
|
+
if (fromNpmrc === 'invalid') return null
|
|
480
|
+
if (typeof fromNpmrc === 'number') return fromNpmrc * 60 * 1000
|
|
481
|
+
return DEFAULT_RELEASE_AGE_WINDOW_MS
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** @returns minutes, `'invalid'` when the key is present but unreadable, or null when absent. */
|
|
485
|
+
function readReleaseAgeMinutesFromWorkspace(dir: string): number | 'invalid' | null {
|
|
486
|
+
let yaml: string
|
|
487
|
+
try { yaml = readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8') } catch { return null }
|
|
488
|
+
if (!/^minimumReleaseAge\s*:/m.test(yaml)) return null
|
|
489
|
+
const match = /^minimumReleaseAge:\s*(\d+)\s*(?:#.*)?$/m.exec(yaml)
|
|
490
|
+
if (match === null) return 'invalid'
|
|
491
|
+
const minutes = Number(match[1])
|
|
492
|
+
return Number.isFinite(minutes) ? minutes : 'invalid'
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** @returns minutes, `'invalid'` when the key is present but unreadable, or null when absent. */
|
|
496
|
+
function readReleaseAgeMinutesFromNpmrc(dir: string): number | 'invalid' | null {
|
|
497
|
+
let npmrc: string
|
|
498
|
+
try { npmrc = readFileSync(join(dir, '.npmrc'), 'utf8') } catch { return null }
|
|
499
|
+
if (!/^minimum-release-age\s*=/m.test(npmrc)) return null
|
|
500
|
+
const match = /^minimum-release-age\s*=\s*(\d+)\s*(?:#.*)?$/m.exec(npmrc)
|
|
501
|
+
if (match === null) return 'invalid'
|
|
502
|
+
const minutes = Number(match[1])
|
|
503
|
+
return Number.isFinite(minutes) ? minutes : 'invalid'
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* How long is left in the fresh-release wait, when pnpm named the publish
|
|
508
|
+
* time in its refusal (#919). Null when the output has no usable timestamp.
|
|
509
|
+
*/
|
|
510
|
+
export function remainingReleaseAgeWait(
|
|
511
|
+
output: string,
|
|
512
|
+
now = Date.now(),
|
|
513
|
+
windowMs = DEFAULT_RELEASE_AGE_WINDOW_MS,
|
|
514
|
+
): number | null {
|
|
515
|
+
const match = /published at (\d{4}-\d{2}-\d{2}T[\d:.]+Z)/i.exec(output)
|
|
516
|
+
if (match === null) return null
|
|
517
|
+
const published = Date.parse(match[1]!)
|
|
518
|
+
if (!Number.isFinite(published)) return null
|
|
519
|
+
return Math.max(0, published + windowMs - now)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Human remaining-wait fragment for the desktop release-age note. */
|
|
523
|
+
function formatRemainingReleaseAgeWait(remainingMs: number): { zh: string, en: string } {
|
|
524
|
+
if (remainingMs <= 0) {
|
|
525
|
+
return { zh: '(现在再试一次即可)', en: ' (try again now)' }
|
|
526
|
+
}
|
|
527
|
+
const hours = Math.max(1, Math.ceil(remainingMs / (60 * 60 * 1000)))
|
|
528
|
+
return {
|
|
529
|
+
zh: `(大约还剩 ${hours} 小时)`,
|
|
530
|
+
en: ` (about ${hours} hour${hours === 1 ? '' : 's'} left)`,
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
426
534
|
/**
|
|
427
535
|
* Whether pnpm failed because the running host holds the package's files
|
|
428
536
|
* open, so nothing pnpm runs from inside that host can replace them.
|
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
|
|
|
@@ -96,7 +131,14 @@ function selfUpdateMessage(spec: string): string {
|
|
|
96
131
|
const at = spec.indexOf('@', 1)
|
|
97
132
|
const version = at === -1 ? '' : spec.slice(at + 1)
|
|
98
133
|
const target = version === '' ? spec : `${registryNameOf(spec) ?? spec}@${version}`
|
|
99
|
-
|
|
134
|
+
// Two paths that used to be named here do not exist on dsh 0.2.1, and a
|
|
135
|
+
// reporter proved both (#909): `dsh plugin --profile desktop` is refused
|
|
136
|
+
// outright, and the app's Settings → Plugins has no update entry — its
|
|
137
|
+
// manager refuses re-adding a package that is already declared. The only
|
|
138
|
+
// route left is pnpm run against the profile with the app fully quit, which
|
|
139
|
+
// is what the reporter verified. `--config.minimum-release-age=0` is the
|
|
140
|
+
// market's own one-shot flag, spelled so it works in every shell.
|
|
141
|
+
return `市场不能在自己的请求里更新自己:桌面端的移除会先把市场从正在运行的组合里摘掉,补装那一步就执行不到了(#900),而应用内的「设置 → 插件」没有更新入口(#909)。要更新${version === '' ? '' : ` 到 ${version}`}:完全退出 DSH Desktop,在 profile 目录执行 \`pnpm add ${target} --config.minimum-release-age=0\`(没有 pnpm 时用 \`npx pnpm add …\`),然后重新启动。Windows 的 profile 一般在 %USERPROFILE%\\.dsh\\profiles\\desktop。 / The market cannot update itself: the desktop app unloads it from the running composition first, so the reinstall never runs (#900), and the app's Settings → Plugins has no update entry (#909). To update${version === '' ? '' : ` to ${version}`}: quit DSH Desktop completely, run \`pnpm add ${target} --config.minimum-release-age=0\` in the profile directory (use \`npx pnpm add …\` if pnpm is not installed), then start DSH again. On Windows the profile is usually %USERPROFILE%\\.dsh\\profiles\\desktop.`
|
|
100
142
|
}
|
|
101
143
|
|
|
102
144
|
/** The manager's own refusal code, whether it was returned or thrown. */
|
|
@@ -261,12 +303,21 @@ export function createOfficialDesktopRuntime(
|
|
|
261
303
|
cancelled: current.cancelled || result.application === 'cancelled',
|
|
262
304
|
// No package result means the manager stopped before it ran pnpm: the
|
|
263
305
|
// profile is untouched, and the route must not try to "restore" it.
|
|
264
|
-
|
|
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)),
|
|
309
|
+
// The manager's own code, kept machine-readable (#909): a client can
|
|
310
|
+
// tell a self-update refusal from an ordinary failure.
|
|
311
|
+
...(ok ? {} : { refusalCode: managementCode(result.error) }),
|
|
265
312
|
}
|
|
266
313
|
}, (error): InstallResult => {
|
|
267
314
|
const message = detail(error)
|
|
268
315
|
progress.error = message
|
|
269
|
-
return {
|
|
316
|
+
return {
|
|
317
|
+
...failure(message, 127),
|
|
318
|
+
wroteNothing: managementCode(error) !== undefined,
|
|
319
|
+
...(managementCode(error) === undefined ? {} : { refusalCode: managementCode(error) }),
|
|
320
|
+
}
|
|
270
321
|
}).finally(() => {
|
|
271
322
|
progress.active = false
|
|
272
323
|
progress.cancelling = false
|
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/routes.ts
CHANGED
|
@@ -41,7 +41,7 @@ import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnaps
|
|
|
41
41
|
import { readHostRecoveryView, restoreHostRecoveryBackup } from './host-recovery.ts'
|
|
42
42
|
import { trialValidate } from './trial.ts'
|
|
43
43
|
import { catalogRepoKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, lookupRepoFromUrl, pinnedAllowBuildsKeys, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, sourceFallbackFor, workspaceProtocolDeps } from './sources.ts'
|
|
44
|
-
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
44
|
+
import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuildEntries, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, pnpmRefusedYoungRelease, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
|
|
45
45
|
import { classifyPnpmFailure } from './pnpm-compat.ts'
|
|
46
46
|
import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
|
|
47
47
|
import {
|
|
@@ -451,6 +451,14 @@ function pnpmFailureMessage(result: { exitCode: number | null; stdout: string; s
|
|
|
451
451
|
const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)
|
|
452
452
|
if (failure === null) return undefined
|
|
453
453
|
if (failure.code === 'ignored-builds' || failure.code === 'git-prepare-not-allowed') return undefined
|
|
454
|
+
// Desktop release-age note is already the actionable sentence (#919). Do
|
|
455
|
+
// not cover it with the classifier's "market already retried" claim —
|
|
456
|
+
// install/uninstall fall through to stderr via failureText when error is
|
|
457
|
+
// undefined.
|
|
458
|
+
if (failure.code === 'release-age-violation'
|
|
459
|
+
&& /没有自动重试|no automatic retry happens here/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
460
|
+
return undefined
|
|
461
|
+
}
|
|
454
462
|
return failure.message
|
|
455
463
|
}
|
|
456
464
|
|
|
@@ -4817,7 +4825,12 @@ sendJson(response, 200, { updates })
|
|
|
4817
4825
|
// verify restoration, inspect this profile" warning this block
|
|
4818
4826
|
// raises on a failed rollback would be alarm about an untouched
|
|
4819
4827
|
// profile.
|
|
4820
|
-
&& result.wroteNothing !== true
|
|
4828
|
+
&& result.wroteNothing !== true
|
|
4829
|
+
// pnpm ran, then refused a too-young release without writing
|
|
4830
|
+
// (#919). Same untouched profile; skipping the rollback keeps
|
|
4831
|
+
// the release-age reason visible instead of covering it with a
|
|
4832
|
+
// false "restoration could not be verified" alarm.
|
|
4833
|
+
&& !pnpmRefusedYoungRelease(result)) {
|
|
4821
4834
|
if (pnpmBlockedByOpenFiles(result)) {
|
|
4822
4835
|
const kept = keepLockedBuild()
|
|
4823
4836
|
rollbackOk = kept.ok
|
|
@@ -5172,7 +5185,10 @@ sendJson(response, 200, { updates })
|
|
|
5172
5185
|
// ever sees this when something really is unbootable.
|
|
5173
5186
|
...(() => { const orphans = orphanBundles(); return orphans.length > 0 ? { orphanBundles: orphans } : {} })(),
|
|
5174
5187
|
staleReason: staleReason ?? undefined,
|
|
5175
|
-
|
|
5188
|
+
// The host's own refusal code when it stopped before pnpm
|
|
5189
|
+
// (#909): `market-self-update` tells a client that the button
|
|
5190
|
+
// cannot work on this host, not that the update was broken.
|
|
5191
|
+
failureCode: versionFailureCode ?? result.refusalCode ?? undefined,
|
|
5176
5192
|
renamedTo: renamedTo ?? undefined,
|
|
5177
5193
|
removedDeclaration: removedDeclaration ?? undefined,
|
|
5178
5194
|
...(droppedBundles.length > 0 ? { droppedBundles } : {}),
|
|
@@ -5183,8 +5199,14 @@ sendJson(response, 200, { updates })
|
|
|
5183
5199
|
// before running pnpm there is no classifier for its words and
|
|
5184
5200
|
// no rollback behind it, so without this the answer carried no
|
|
5185
5201
|
// reason at all.
|
|
5202
|
+
// Release-age on Desktop (#919): withHoistRecovery already wrote
|
|
5203
|
+
// the wait note into stderr (and stripped the false "already
|
|
5204
|
+
// retried" classifier). Prefer that over pnpmFailureMessage,
|
|
5205
|
+
// which would re-surface the classifier alone — the client
|
|
5206
|
+
// shows body.error first.
|
|
5186
5207
|
error: versionFailureError ?? renamedError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError
|
|
5187
5208
|
?? (result.wroteNothing === true && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
|
|
5209
|
+
?? (pnpmRefusedYoungRelease(result) && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
|
|
5188
5210
|
?? pnpmFailureMessage(result) ?? undefined,
|
|
5189
5211
|
exitCode: result.exitCode,
|
|
5190
5212
|
timedOut: result.timedOut,
|
package/src/update-api-v1.ts
CHANGED
|
@@ -76,9 +76,30 @@ function bool(value: unknown): boolean {
|
|
|
76
76
|
return value === true
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Host refusal codes that may travel through `failureCode` unchanged.
|
|
81
|
+
*
|
|
82
|
+
* The route forwards the desktop manager's own codes (#909); the v1 API only
|
|
83
|
+
* re-publishes the ones a caller can act on, so an internal code cannot leak
|
|
84
|
+
* by accident. `market-self-update` is the one this exists for: the market
|
|
85
|
+
* cannot update itself on a host whose manager refuses re-adds, and a caller
|
|
86
|
+
* should be able to say so instead of retrying.
|
|
87
|
+
*/
|
|
88
|
+
const FORWARDED_FAILURE_CODES = new Set([
|
|
89
|
+
'DOWNGRADE_DETECTED',
|
|
90
|
+
'RESOLVED_VERSION_MISMATCH',
|
|
91
|
+
'market-self-update',
|
|
92
|
+
'already-installed',
|
|
93
|
+
'bundle-in-use',
|
|
94
|
+
'not-removable',
|
|
95
|
+
'stop-profile',
|
|
96
|
+
'management-required',
|
|
97
|
+
'incompatible-version',
|
|
98
|
+
])
|
|
99
|
+
|
|
79
100
|
function failureCode(status: number, body: Record<string, unknown>): string {
|
|
80
101
|
const explicit = text(body.failureCode)
|
|
81
|
-
if (explicit
|
|
102
|
+
if (explicit !== null && FORWARDED_FAILURE_CODES.has(explicit)) return explicit
|
|
82
103
|
if (body.agentsBusy === true) return 'AGENTS_RUNNING'
|
|
83
104
|
if (body.busy === true || status === 409) return 'OPERATION_BUSY'
|
|
84
105
|
if (body.stale === true && body.staleReason === 'release-age') return 'RELEASE_TOO_FRESH'
|