dshmarket 1.66.38 → 1.66.40

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,6 +11,8 @@ 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://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
+
14
16
  ![dsh-market](assets/demo-en.png)
15
17
 
16
18
  One-click themes: install, switch live, no restart.
@@ -42,7 +44,6 @@ its own dsh: it may be older than the one `npm` would give you (#139).
42
44
  ## What you get
43
45
 
44
46
  - **Browse & search** the full community catalog (4200+ plugins, growing daily) — category filters, star counts, top/new sorting, bilingual descriptions that follow your UI language
45
- - **The same catalog in the official Discover tab** — the list is also published as a plugin market index. On dsh 0.2.1+ add `https://awesome-dsh-plugin.com/index.json` under **Plugins → Discover → Index sources**, and the host's own Discover tab and its `plugin_market` agent tool search the same entries (the feature ships behind dsh's optional plugin-market bundle)
46
47
  - **Host-aware discovery** — cards show the DSH requirement declared by `engines.dsh` or lockstep `@deepseek-ai/dsh-*` peers; an opt-in filter hides only confirmed mismatches with the running host. Undeclared, malformed, unavailable, and GitHub-only entries remain visible rather than being guessed incompatible
47
48
  - **Screenshots** — AppStore-style screenshots, auto-carousel when there's more than one, click to preview full-size: author-curated shots show right on the card (zero extra requests); plugins without curated shots fall back to automatic README extraction once you open the install dialog. Images load from GitHub hosting only
48
49
  - **Comments** — every card opens the plugin's discussion thread in place. It is the same thread its pages on [dshmarket.com](https://dshmarket.com) and the [catalog](https://awesome-dsh-plugin.com) show, so a plugin has one conversation rather than three. Backed by GitHub Discussions through giscus: it loads when you open it, needs a GitHub account only to post, and the note above it says plainly that opening it contacts giscus.app and GitHub. On local dsh web, reading stays embedded while a dedicated GitHub action opens the exact discussion in a new tab for sign-in and posting, so the cross-site return never carries or depends on the host session
package/README.zh.md CHANGED
@@ -11,6 +11,8 @@
11
11
 
12
12
  装在 DeepSeek Harness 里的插件市场。打开设置 → **插件市场** → 逛一逛,点一下,装好。设置入口在 dsh 0.2.0 及以前位于侧边栏,0.2.1 起移到了账号菜单里。
13
13
 
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
+
14
16
  ![dsh-market](assets/demo-zh.png)
15
17
 
16
18
  主题一键换:装完即生效,点一下切换,不用重启。
@@ -37,7 +39,6 @@ dsh plugin --profile web add dshmarket
37
39
  ## 你会得到
38
40
 
39
41
  - **逛与搜**——完整社区目录(4200+ 插件,每天在涨),分类筛选、star 数、最热/最新排序,中英描述跟随界面语言
40
- - **同一份目录也能进官方 Discover**——这份列表同时发布为插件市场索引:dsh 0.2.1 起,在 **插件 → 发现 → 索引源** 里添加 `https://awesome-dsh-plugin.com/index.json`,宿主自己的发现页和它的 `plugin_market` 智能体工具就能搜索同一批条目(该能力挂在 dsh 可选的 plugin-market bundle 上)
41
42
  - **按宿主发现**——卡片展示插件通过 `engines.dsh` 或同版本线 `@deepseek-ai/dsh-*` peer 声明的 DSH 要求;可选筛选只隐藏与当前宿主明确不匹配的插件。未声明、格式异常、暂时取不到清单及仅 GitHub 发布的条目仍保持可见,不猜成不兼容
42
43
  - **截图展示**——App Store 式截图,多图自动轮播,点开还能看大图;作者在 registry 里策展的截图列表卡片就直接显示(零额外请求),没有策展的插件则在打开安装弹窗时自动从 README 抽取;图片仅从 GitHub 图床加载
43
44
  - **评论**——每张卡片都能就地打开该插件的讨论。它和插件在 [dshmarket.com](https://dshmarket.com) 与[目录站](https://awesome-dsh-plugin.com)上的页面共用同一条讨论,一个插件只有一处对话,而不是三处。底层是 GitHub Discussions(经由 giscus):打开即加载,只有发表评论才需要 GitHub 账号;说明里也直说打开会连接 giscus.app 与 GitHub。本地 dsh web 继续内嵌评论供阅读,登录和发表则通过专用 GitHub 入口在新标签页打开当前插件的精确讨论,不让跨站回跳携带或依赖宿主会话
@@ -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.",
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.
@@ -82,7 +82,14 @@ function selfUpdateMessage(spec) {
82
82
  const at = spec.indexOf('@', 1);
83
83
  const version = at === -1 ? '' : spec.slice(at + 1);
84
84
  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.`;
85
+ // Two paths that used to be named here do not exist on dsh 0.2.1, and a
86
+ // reporter proved both (#909): `dsh plugin --profile desktop` is refused
87
+ // outright, and the app's Settings → Plugins has no update entry — its
88
+ // manager refuses re-adding a package that is already declared. The only
89
+ // route left is pnpm run against the profile with the app fully quit, which
90
+ // is what the reporter verified. `--config.minimum-release-age=0` is the
91
+ // market's own one-shot flag, spelled so it works in every shell.
92
+ 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
93
  }
87
94
  /** The manager's own refusal code, whether it was returned or thrown. */
88
95
  function managementCode(error) {
@@ -244,11 +251,18 @@ _profileDirectory) {
244
251
  // No package result means the manager stopped before it ran pnpm: the
245
252
  // profile is untouched, and the route must not try to "restore" it.
246
253
  wroteNothing: !ok && result.packageResult === undefined,
254
+ // The manager's own code, kept machine-readable (#909): a client can
255
+ // tell a self-update refusal from an ordinary failure.
256
+ ...(ok ? {} : { refusalCode: managementCode(result.error) }),
247
257
  };
248
258
  }, (error) => {
249
259
  const message = detail(error);
250
260
  progress.error = message;
251
- return { ...failure(message, 127), wroteNothing: managementCode(error) !== undefined };
261
+ return {
262
+ ...failure(message, 127),
263
+ wroteNothing: managementCode(error) !== undefined,
264
+ ...(managementCode(error) === undefined ? {} : { refusalCode: managementCode(error) }),
265
+ };
252
266
  }).finally(() => {
253
267
  progress.active = false;
254
268
  progress.cancelling = false;
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
  /**
@@ -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.
@@ -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.38",
4
+ "version": "1.66.40",
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",
@@ -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
@@ -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
  /**
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.
@@ -96,7 +96,14 @@ function selfUpdateMessage(spec: string): string {
96
96
  const at = spec.indexOf('@', 1)
97
97
  const version = at === -1 ? '' : spec.slice(at + 1)
98
98
  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.`
99
+ // Two paths that used to be named here do not exist on dsh 0.2.1, and a
100
+ // reporter proved both (#909): `dsh plugin --profile desktop` is refused
101
+ // outright, and the app's Settings → Plugins has no update entry — its
102
+ // manager refuses re-adding a package that is already declared. The only
103
+ // route left is pnpm run against the profile with the app fully quit, which
104
+ // is what the reporter verified. `--config.minimum-release-age=0` is the
105
+ // market's own one-shot flag, spelled so it works in every shell.
106
+ 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
107
  }
101
108
 
102
109
  /** The manager's own refusal code, whether it was returned or thrown. */
@@ -262,11 +269,18 @@ export function createOfficialDesktopRuntime(
262
269
  // No package result means the manager stopped before it ran pnpm: the
263
270
  // profile is untouched, and the route must not try to "restore" it.
264
271
  wroteNothing: !ok && result.packageResult === undefined,
272
+ // The manager's own code, kept machine-readable (#909): a client can
273
+ // tell a self-update refusal from an ordinary failure.
274
+ ...(ok ? {} : { refusalCode: managementCode(result.error) }),
265
275
  }
266
276
  }, (error): InstallResult => {
267
277
  const message = detail(error)
268
278
  progress.error = message
269
- return { ...failure(message, 127), wroteNothing: managementCode(error) !== undefined }
279
+ return {
280
+ ...failure(message, 127),
281
+ wroteNothing: managementCode(error) !== undefined,
282
+ ...(managementCode(error) === undefined ? {} : { refusalCode: managementCode(error) }),
283
+ }
270
284
  }).finally(() => {
271
285
  progress.active = false
272
286
  progress.cancelling = false
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'