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 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` — or `https://dshmarket.com/index.json`, the same document. The host's Discover tab and the `plugin_market` agent tool then search it.
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
  ![dsh-market](assets/demo-en.png)
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`——或 `https://dshmarket.com/index.json`,同一份文档。宿主自己的发现页和 `plugin_market` 智能体工具就能搜索它。
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
  ![dsh-market](assets/demo-zh.png)
17
17
 
@@ -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://awesome-dsh-plugin.com/index.json —— 官方发现页和 plugin_market 智能体工具会搜索同一份目录。",
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://awesome-dsh-plugin.com/index.json under Plugins → Discover → Index sources — the official Discover tab and the plugin_market agent tool then search the same catalog.",
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, and the door the market would open on a plain
335
- // `dsh web` host is shut here — so say when the same button starts
336
- // working, and what to use meanwhile (#852).
337
- const note = unavailableOption === RELEASE_AGE_OVERRIDE
338
- ? `这个插件的新版本比本 profile 的 minimumReleaseAge(新版本等待期)还新,pnpm 因此拒绝了本次改动(改别的插件也一样)。官方桌面端由应用自己执行安装,不接受市场的临时放行参数(${unavailableOption}),所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**;想现在就更新,可以在普通的 dsh web 里装。 / The plugin's new version is younger than this profile's minimumReleaseAge, so pnpm refused this change (a change to another plugin too). The official desktop app runs the install itself and takes no one-shot bypass from the market (${unavailableOption}), so no automatic retry happens here. **Update again once the version is past the wait**; to update right now, install from plain dsh web.`
339
- : `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 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.`;
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.
@@ -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
- return `市场不能在自己的请求里先卸载自己:桌面端的移除会先把市场从正在运行的组合里摘掉,补装那一步就再也执行不到了(#900)。请在桌面端的「设置 → 插件」里更新市场${version === '' ? '' : ` 到 ${version}`},或完全退出 DSH 后在终端执行 \`dsh plugin --profile desktop add ${target}\`。 / The market cannot remove itself to update: the desktop app unloads it from the running composition first, so the reinstall never runs (#900). Update it from the desktop app's Settings → Plugins${version === '' ? '' : ` (to ${version})`}, or quit DSH completely and run \`dsh plugin --profile desktop add ${target}\` in a terminal.`;
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
- wroteNothing: !ok && result.packageResult === undefined,
286
+ // A pre-install host check (#912) is the same shape even when the
287
+ // manager attached a package result to its refusal.
288
+ wroteNothing: !ok && (result.packageResult === undefined || prePnpmHostFailure(result)),
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 { ...failure(message, 127), wroteNothing: managementCode(error) !== undefined };
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;
@@ -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
- failureCode: versionFailureCode ?? undefined,
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,
@@ -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.
@@ -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. */
@@ -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 === 'DOWNGRADE_DETECTED' || explicit === 'RESOLVED_VERSION_MISMATCH')
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.39",
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 = () => {
@@ -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
  }
@@ -14,7 +14,7 @@ export const zh = {
14
14
  setSidebarHint: '在侧栏「插件」下方显示市场。设置里的入口不变。',
15
15
  setSidebarOnlyDoorHint: '这个宿主没有市场设置页,这一行就是进入市场的入口。',
16
16
  setIndexSource: '官方 Discover 索引',
17
- setIndexSourceHint: 'dsh 0.2.1 起,官方的「插件 → 发现 → 索引源」可以添加 https://awesome-dsh-plugin.com/index.json —— 官方发现页和 plugin_market 智能体工具会搜索同一份目录。',
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://awesome-dsh-plugin.com/index.json under Plugins → Discover → Index sources — the official Discover tab and the plugin_market agent tool then search the same catalog.',
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, and the door the market would open on a plain
341
- // `dsh web` host is shut here — so say when the same button starts
342
- // working, and what to use meanwhile (#852).
343
- const note = unavailableOption === RELEASE_AGE_OVERRIDE
344
- ? `这个插件的新版本比本 profile 的 minimumReleaseAge(新版本等待期)还新,pnpm 因此拒绝了本次改动(改别的插件也一样)。官方桌面端由应用自己执行安装,不接受市场的临时放行参数(${unavailableOption}),所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**;想现在就更新,可以在普通的 dsh web 里装。 / The plugin's new version is younger than this profile's minimumReleaseAge, so pnpm refused this change (a change to another plugin too). The official desktop app runs the install itself and takes no one-shot bypass from the market (${unavailableOption}), so no automatic retry happens here. **Update again once the version is past the wait**; to update right now, install from plain dsh web.`
345
- : `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 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.`
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.
@@ -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
- return `市场不能在自己的请求里先卸载自己:桌面端的移除会先把市场从正在运行的组合里摘掉,补装那一步就再也执行不到了(#900)。请在桌面端的「设置 → 插件」里更新市场${version === '' ? '' : ` 到 ${version}`},或完全退出 DSH 后在终端执行 \`dsh plugin --profile desktop add ${target}\`。 / The market cannot remove itself to update: the desktop app unloads it from the running composition first, so the reinstall never runs (#900). Update it from the desktop app's Settings → Plugins${version === '' ? '' : ` (to ${version})`}, or quit DSH completely and run \`dsh plugin --profile desktop add ${target}\` in a terminal.`
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
- wroteNothing: !ok && result.packageResult === undefined,
306
+ // A pre-install host check (#912) is the same shape even when the
307
+ // manager attached a package result to its refusal.
308
+ wroteNothing: !ok && (result.packageResult === undefined || prePnpmHostFailure(result)),
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 { ...failure(message, 127), wroteNothing: managementCode(error) !== undefined }
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
@@ -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
- failureCode: versionFailureCode ?? undefined,
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,
@@ -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 === 'DOWNGRADE_DETECTED' || explicit === 'RESOLVED_VERSION_MISMATCH') return 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'