dshmarket 1.66.34 → 1.66.36

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
@@ -46,7 +46,7 @@ its own dsh: it may be older than the one `npm` would give you (#139).
46
46
  - **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
47
47
  - **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
48
48
  - **Favorites** — bookmark plugins and themes from Discover or the Themes tab; a dedicated Favorites tab lists them with search, sort, and install actions. Bookmarks persist in the profile's market state (`state.json`); entries that leave the catalog can be cleared in one click
49
- - **Groups** — organise installed plugins into named groups (rename, delete, and per-group search); a plugin lives in one group and ungrouped is the default. Purely organisational: the panel says so, and nothing about enable state changes. The grouping itself is local state in the profile's `state.json`
49
+ - **Groups** — organise installed plugins into named groups (rename, delete, and per-group search); a plugin lives in one group and ungrouped is the default. A group carries its own switch: all-on, all-off, or the mixed state its members are really in, so a working set can be switched as a unit without touching the rest. A group holds at most one theme. The grouping itself is local state in the profile's `state.json`
50
50
  - **Notes** — write your own one-line description for any installed plugin and it replaces the author's on that row, so a shelf of forty plugins answers "why did I install this" in your words. Stored locally beside groups and favorites, never sent anywhere
51
51
  - **Themes** — a dedicated tab for community themes and skins: install → active immediately, switch with one click (themes are mutually exclusive, your choice survives restarts), uninstall to revert
52
52
  - **One-click install** — confirm the source, watch live progress; most plugins go live after a page refresh, no restart
package/README.zh.md CHANGED
@@ -41,7 +41,7 @@ dsh plugin --profile web add dshmarket
41
41
  - **截图展示**——App Store 式截图,多图自动轮播,点开还能看大图;作者在 registry 里策展的截图列表卡片就直接显示(零额外请求),没有策展的插件则在打开安装弹窗时自动从 README 抽取;图片仅从 GitHub 图床加载
42
42
  - **评论**——每张卡片都能就地打开该插件的讨论。它和插件在 [dshmarket.com](https://dshmarket.com) 与[目录站](https://awesome-dsh-plugin.com)上的页面共用同一条讨论,一个插件只有一处对话,而不是三处。底层是 GitHub Discussions(经由 giscus):打开即加载,只有发表评论才需要 GitHub 账号;说明里也直说打开会连接 giscus.app 与 GitHub。本地 dsh web 继续内嵌评论供阅读,登录和发表则通过专用 GitHub 入口在新标签页打开当前插件的精确讨论,不让跨站回跳携带或依赖宿主会话
43
43
  - **收藏**——在「发现」或「主题」页点书签即可收藏插件/主题;「收藏」Tab 集中展示,支持搜索、排序与安装。书签写入 profile 的市场状态(`state.json`);已下架条目可一键清除
44
- - **分组**——把已安装插件整理进具名分组(可重命名、删除,分组内单独搜索);一个插件属于一个分组,默认是「未分组」。它只用于整理,面板上写明了这一点,不改变任何启用状态。分组本身是 profile `state.json` 里的本地状态
44
+ - **分组**——把已安装插件整理进具名分组(可重命名、删除,分组内单独搜索);一个插件属于一个分组,默认是「未分组」。每个分组自带开关:全开、全关,或成员当前真实处于的「混合」状态——一组一起开合,不动其余插件。一个分组最多容纳一个主题。分组本身是 profile `state.json` 里的本地状态
45
45
  - **备注**——给任何已安装插件写一行你自己的说明,它会在这行**代替作者简介**显示——四十个插件的列表因此能用你自己的话回答「我当初为什么装它」。和分组、收藏一样只存在本地,不上传
46
46
  - **主题**——独立主题页:装完立即生效,点一下切换(主题互斥、选择跨重启保留),卸载即恢复
47
47
  - **一键安装**——确认来源,实时进度;多数插件刷新页面即可用,无需重启
package/client/client.js CHANGED
@@ -243,6 +243,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
243
243
  setChannelSwitchHint: "该版本低于当前已安装版本。",
244
244
  setSelfRemove: "卸载插件市场",
245
245
  setSelfRemoveHint: "从这个 profile 卸载市场。已装的其它插件不受影响。",
246
+ setSelfRemoveRemote: "这个页面不是从本机打开的,市场没法从这里卸载自己。请到运行 DSH 的那台机器上移除:桌面端用「设置 → 插件」,其它环境用命令行。",
246
247
  setSelfConfirm: "确定要卸载插件市场吗?卸载后需使用命令行重新安装。",
247
248
  setSelfPurge: "同时清除市场自己的数据",
248
249
  setSelfPurgeOn: "将删除:市场的停用清单与自定义分组,以及它写进配置文件的停用记录——被停用的插件将恢复运行。不涉及密码或密钥,市场从不把它们存在磁盘上。",
@@ -980,6 +981,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
980
981
  setChannelSwitchHint: "This version is lower than the one installed.",
981
982
  setSelfRemove: "Uninstall the plugin market",
982
983
  setSelfRemoveHint: "Uninstall the market from this profile. Your other plugins are untouched.",
984
+ setSelfRemoveRemote: "This page was not opened on the machine running DSH, so the market cannot remove itself from here. Remove it there: Settings → Plugins on the desktop app, or the command line elsewhere.",
983
985
  setSelfConfirm: "Uninstall the plugin market? Reinstalling it requires the command line.",
984
986
  setSelfPurge: "Also clear the market's own data",
985
987
  setSelfPurgeOn: "Will delete: the market's disable list and custom groups, plus the disable entries it wrote into your config file — plugins it switched off will run again. No passwords or tokens are involved; the market never stores those on disk.",
@@ -15116,6 +15118,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
15116
15118
  githubProxyCustom: typeof body.githubProxyCustom === "string" ? body.githubProxyCustom : null,
15117
15119
  githubProxyManaged: body.githubProxyManaged === true,
15118
15120
  selfManaged: body.selfManaged !== false,
15121
+ selfUninstallReachable: body.selfUninstallReachable !== false,
15119
15122
  buildEnv: body.buildEnv ?? {}
15120
15123
  };
15121
15124
  }
@@ -15222,6 +15225,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
15222
15225
  githubProxyCustom: null,
15223
15226
  githubProxyManaged: false,
15224
15227
  selfManaged: true,
15228
+ selfUninstallReachable: true,
15225
15229
  buildEnv: {}
15226
15230
  });
15227
15231
  }
@@ -15548,7 +15552,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
15548
15552
  onClick: () => {
15549
15553
  onSaveEnv();
15550
15554
  }
15551
- }, t("setBuildEnvSave")))), status?.selfManaged === true ? row(t("setSelfRemove"), t("setSelfRemoveHint"), phase === "confirming" || busy ? null : (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
15555
+ }, t("setBuildEnvSave")))), status?.selfManaged === true ? row(t("setSelfRemove"), status.selfUninstallReachable ? t("setSelfRemoveHint") : t("setSelfRemoveRemote"), status.selfUninstallReachable && phase !== "confirming" && !busy ? (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.Button, {
15552
15556
  variant: "outline",
15553
15557
  size: "sm",
15554
15558
  className: Market_module_css_default.setDanger,
@@ -15556,7 +15560,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
15556
15560
  onClick: () => {
15557
15561
  setPhase("confirming");
15558
15562
  }
15559
- }, t("setSelfRemove"))) : null, status?.selfManaged === true && (phase === "confirming" || busy) ? (0, react.createElement)("div", { className: Market_module_css_default.setConfirm }, (0, react.createElement)("div", { className: Market_module_css_default.setHint }, t("setSelfConfirm")), (0, react.createElement)("label", { className: Market_module_css_default.setCheck }, (0, react.createElement)("input", {
15563
+ }, t("setSelfRemove")) : null) : null, status?.selfManaged === true && status.selfUninstallReachable && (phase === "confirming" || busy) ? (0, react.createElement)("div", { className: Market_module_css_default.setConfirm }, (0, react.createElement)("div", { className: Market_module_css_default.setHint }, t("setSelfConfirm")), (0, react.createElement)("label", { className: Market_module_css_default.setCheck }, (0, react.createElement)("input", {
15560
15564
  type: "checkbox",
15561
15565
  checked: purge,
15562
15566
  onChange: () => {
@@ -18,8 +18,72 @@ function detail(error) {
18
18
  return error.message;
19
19
  if (error === undefined)
20
20
  return 'plugin manager did not explain the failure';
21
+ const code = managementCode(error);
22
+ const copy = code === undefined ? undefined : REFUSAL_COPY[code];
23
+ // The code stays on the line: it is what the log and the issue report carry.
24
+ if (copy !== undefined)
25
+ return `${copy.zh} / ${copy.en} (${code})`;
26
+ const message = error.message;
27
+ if (typeof message === 'string' && message !== '')
28
+ return message;
21
29
  return JSON.stringify(error);
22
30
  }
31
+ /**
32
+ * What the manager's refusal means, in the user's terms.
33
+ *
34
+ * These codes are the manager's localization keys, not sentences; they used to
35
+ * reach the page as `{"code":"already-installed"}` and, on the update route,
36
+ * wrapped in "restoration of the previous build could not be verified" — alarm
37
+ * about a profile that was never touched (#897). The bilingual half is picked
38
+ * by the same `localizeBilingual` every other server message goes through.
39
+ */
40
+ const REFUSAL_COPY = {
41
+ 'already-installed': {
42
+ zh: '桌面端认为这个包已经装在 profile 里,因此在运行 pnpm 之前就拒绝了这次安装,profile 没有任何改动',
43
+ en: 'the desktop app treats this package as already installed and refused the install before pnpm ran, so the profile is unchanged',
44
+ },
45
+ 'not-removable': {
46
+ zh: '这个插件由桌面端自己管理,市场不能移除它',
47
+ en: 'the desktop app manages this plugin itself, so the market cannot remove it',
48
+ },
49
+ 'bundle-in-use': {
50
+ zh: '这个插件正在使用中,桌面端不允许现在移除',
51
+ en: 'the plugin is in use, and the desktop app will not remove it right now',
52
+ },
53
+ 'stop-profile': {
54
+ zh: '要移除它得先停止这个 profile',
55
+ en: 'removing it requires stopping this profile first',
56
+ },
57
+ 'management-required': {
58
+ zh: '这个 profile 由桌面端自己管理,市场不能直接改动',
59
+ en: 'this profile is managed by the desktop app itself, so the market cannot change it',
60
+ },
61
+ 'incompatible-version': {
62
+ zh: '这个版本与当前 DSH 不兼容,宿主拒绝安装',
63
+ en: 'this version is not compatible with the running DSH, so the host refused it',
64
+ },
65
+ };
66
+ /**
67
+ * The market's own package names. A desktop profile installs it from npm as
68
+ * `dshmarket`; a fork or a local checkout can declare the repository's name.
69
+ */
70
+ const SELF_NAMES = new Set(['dshmarket', 'dsh-market']);
71
+ /**
72
+ * Why the market will not remove itself to update, and what to do instead.
73
+ *
74
+ * The manager's `removeBundle` drops the bundle from the running composition
75
+ * before pnpm runs. For every other package that is a normal unload; for the
76
+ * market it unloads the very code that was going to install the new version,
77
+ * so the reinstall never happens and the profile is left with the bundle row
78
+ * gone and the dependency still declared (#900). The app's own Plugins page
79
+ * performs the same two steps from outside the market and survives.
80
+ */
81
+ function selfUpdateMessage(spec) {
82
+ const at = spec.indexOf('@', 1);
83
+ const version = at === -1 ? '' : spec.slice(at + 1);
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.`;
86
+ }
23
87
  /** The manager's own refusal code, whether it was returned or thrown. */
24
88
  function managementCode(error) {
25
89
  const code = error?.code;
@@ -60,6 +124,11 @@ async function installOrReinstall(manager, spec, options) {
60
124
  // A refusal we cannot turn into a name is the manager's answer to give.
61
125
  if (name === undefined)
62
126
  return first;
127
+ // Removing the market would unload this very code before the reinstall runs
128
+ // (#900). Say how to update it instead.
129
+ if (SELF_NAMES.has(name)) {
130
+ return { application: 'failed', error: { code: 'market-self-update', message: selfUpdateMessage(spec) } };
131
+ }
63
132
  progress.lastLine = 'Removing the installed copy so the desktop app can install the new version';
64
133
  const removed = await manager.removeBundle(name);
65
134
  if (removed.application === 'failed' || removed.application === 'cancelled')
@@ -172,11 +241,14 @@ _profileDirectory) {
172
241
  stdout: output,
173
242
  stderr: message,
174
243
  cancelled: current.cancelled || result.application === 'cancelled',
244
+ // No package result means the manager stopped before it ran pnpm: the
245
+ // profile is untouched, and the route must not try to "restore" it.
246
+ wroteNothing: !ok && result.packageResult === undefined,
175
247
  };
176
248
  }, (error) => {
177
249
  const message = detail(error);
178
250
  progress.error = message;
179
- return failure(message, 127);
251
+ return { ...failure(message, 127), wroteNothing: managementCode(error) !== undefined };
180
252
  }).finally(() => {
181
253
  progress.active = false;
182
254
  progress.cancelling = false;
package/lib/restart.js CHANGED
@@ -213,6 +213,47 @@ function directLoopbackRequest(request) {
213
213
  export function restartReachableFrom(request) {
214
214
  return directLoopbackRequest(request);
215
215
  }
216
+ /**
217
+ * Whether the request has the exact shape dsh Desktop's own document proxy
218
+ * forwards: a loopback peer, no forwarding headers, and **neither** `Host`
219
+ * nor `Origin`.
220
+ *
221
+ * The desktop app strips `origin`/`host` (and cookies) before forwarding to
222
+ * the internal host (#648), so that absence is the trusted desktop shape
223
+ * rather than a missing signal. It is also not something a browser can
224
+ * produce — every browser request carries `Host` — so a rebinding page or a
225
+ * cross-site POST still lands on the strict path below, carrying the
226
+ * attacker's own names (#678). A local non-browser client could omit both,
227
+ * but such a client can equally set them correctly, so this widens nothing
228
+ * against the threats the fence exists for.
229
+ */
230
+ function desktopForwardedRequest(request) {
231
+ const address = request.socket.remoteAddress;
232
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
233
+ return false;
234
+ if (request.headers.forwarded !== undefined
235
+ || request.headers['x-forwarded-for'] !== undefined
236
+ || request.headers['x-real-ip'] !== undefined)
237
+ return false;
238
+ return request.headers.host === undefined && request.headers.origin === undefined;
239
+ }
240
+ /**
241
+ * Whether a self-uninstall request may proceed.
242
+ *
243
+ * Same door as the restart route, plus the desktop proxy's headerless shape
244
+ * (#903): on Desktop the market's own card is the place users go to remove it,
245
+ * and the fence was refusing every such click because the proxy had stripped
246
+ * the very headers it required. Restart keeps the stricter predicate — it
247
+ * spawns a replacement host, which is not something a desktop-managed profile
248
+ * should get from here.
249
+ */
250
+ export function trustedSelfUninstallRequest(request) {
251
+ return trustedRestartRequest(request) || desktopForwardedRequest(request);
252
+ }
253
+ /** Whether the request that asked for status could uninstall the market (#903). */
254
+ export function selfUninstallReachableFrom(request) {
255
+ return trustedSelfUninstallRequest(request);
256
+ }
216
257
  /** Whether a process-control request came from this Web host on loopback. */
217
258
  export function trustedRestartRequest(request) {
218
259
  if (!directLoopbackRequest(request))
package/lib/routes.js CHANGED
@@ -43,7 +43,7 @@ import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, inval
43
43
  import { createThemeManager } from './themes.js';
44
44
  import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.js';
45
45
  import { resolveDshHome } from './home-paths.js';
46
- import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, restartReachableFrom } from './restart.js';
46
+ import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, trustedSelfUninstallRequest, selfUninstallReachableFrom, restartReachableFrom } from './restart.js';
47
47
  import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
48
48
  import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
49
49
  import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
@@ -3552,6 +3552,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3552
3552
  // Deliberately NOT folded into `restart`, which is also what the
3553
3553
  // settings page reads and writes back as the user's own choice.
3554
3554
  restartReachable: restartReachableFrom(request),
3555
+ // Same door, one notch wider for the desktop proxy (#903): the
3556
+ // card hides a remove button that could only ever answer 403.
3557
+ selfUninstallReachable: selfUninstallReachableFrom(request),
3555
3558
  selfManaged: installed.dshmarket !== undefined || installed['dsh-market'] !== undefined,
3556
3559
  // Whether the host took the market's settings namespace (#677).
3557
3560
  // `unsupported-by-host` is 0.1.7 and newer, where settings come from
@@ -4597,7 +4600,13 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4597
4600
  + (dropped ? '' : ' (nothing to drop: the profile did not declare it under either key)'));
4598
4601
  };
4599
4602
  if ((result.exitCode !== 0 || result.timedOut) && !cancelled && result.busy !== true
4600
- && !pnpmNeverStarted(result)) {
4603
+ && !pnpmNeverStarted(result)
4604
+ // The host refused before running pnpm (#897): nothing was
4605
+ // written, so there is nothing to restore — and the "could not
4606
+ // verify restoration, inspect this profile" warning this block
4607
+ // raises on a failed rollback would be alarm about an untouched
4608
+ // profile.
4609
+ && result.wroteNothing !== true) {
4601
4610
  if (pnpmBlockedByOpenFiles(result)) {
4602
4611
  const kept = keepLockedBuild();
4603
4612
  rollbackOk = kept.ok;
@@ -4941,7 +4950,13 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4941
4950
  // Last, so the specific ones above keep the floor they had: this
4942
4951
  // only fills the case where every named branch said nothing and
4943
4952
  // the user would otherwise get pnpm's stack (#848).
4944
- error: versionFailureError ?? renamedError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError ?? pnpmFailureMessage(result) ?? undefined,
4953
+ // The host's own refusal sentence (#897): when it stopped
4954
+ // before running pnpm there is no classifier for its words and
4955
+ // no rollback behind it, so without this the answer carried no
4956
+ // reason at all.
4957
+ error: versionFailureError ?? renamedError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError
4958
+ ?? (result.wroteNothing === true && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
4959
+ ?? pnpmFailureMessage(result) ?? undefined,
4945
4960
  exitCode: result.exitCode,
4946
4961
  timedOut: result.timedOut,
4947
4962
  stdout: result.stdout,
@@ -5243,8 +5258,13 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5243
5258
  // read as an extra guard while testing nothing: origin-matches-host
5244
5259
  // is already part of what this checks, so no request can fail one
5245
5260
  // and pass the other.
5246
- if (!trustedRestartRequest(request)) {
5247
- sendJson(response, 403, { error: 'self-uninstall is limited to same-origin loopback requests' });
5261
+ if (!trustedSelfUninstallRequest(request)) {
5262
+ // Says what to do, not only which policy refused it: this is the one
5263
+ // button whose failure leaves the user with no other way out of the
5264
+ // page they are looking at (#903).
5265
+ sendJson(response, 403, {
5266
+ error: `不能从这个地址卸载插件市场(只接受本机同源请求)。请在运行 DSH 的机器上执行 \`dsh plugin --profile ${config.profile} remove dshmarket\`;桌面端也可以在它自己的「设置 → 插件」里移除。 / The market can only be removed from the same machine it runs on. Run \`dsh plugin --profile ${config.profile} remove dshmarket\` there, or remove it from the desktop app's Settings → Plugins.`,
5267
+ });
5248
5268
  return;
5249
5269
  }
5250
5270
  try {
@@ -161,6 +161,17 @@ export interface InstallResult {
161
161
  cancelled: boolean;
162
162
  /** Desktop's generation-wide package-operation gate rejected the start. */
163
163
  busy?: boolean;
164
+ /**
165
+ * The host refused before it ran the package manager, so the profile is
166
+ * exactly as it was.
167
+ *
168
+ * The update route answers a failed run by reinstalling the previous build
169
+ * and, when that cannot be verified, tells the user to inspect a profile
170
+ * nothing touched. Desktop's manager has refusals of this kind of its own —
171
+ * it declines a spec it considers already installed before pnpm starts
172
+ * (#897) — and their only true account is "nothing happened".
173
+ */
174
+ wroteNothing?: boolean;
164
175
  /** Package names pnpm reported as having ignored build scripts (ndjson). */
165
176
  ignoredBuilds?: string[];
166
177
  /**
@@ -126,6 +126,19 @@ export declare function servingPort(request: Pick<IncomingMessage, 'headers'>):
126
126
  * grant: the route still runs the full fence, including `Origin`, on the POST.
127
127
  */
128
128
  export declare function restartReachableFrom(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
129
+ /**
130
+ * Whether a self-uninstall request may proceed.
131
+ *
132
+ * Same door as the restart route, plus the desktop proxy's headerless shape
133
+ * (#903): on Desktop the market's own card is the place users go to remove it,
134
+ * and the fence was refusing every such click because the proxy had stripped
135
+ * the very headers it required. Restart keeps the stricter predicate — it
136
+ * spawns a replacement host, which is not something a desktop-managed profile
137
+ * should get from here.
138
+ */
139
+ export declare function trustedSelfUninstallRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
140
+ /** Whether the request that asked for status could uninstall the market (#903). */
141
+ export declare function selfUninstallReachableFrom(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
129
142
  /** Whether a process-control request came from this Web host on loopback. */
130
143
  export declare function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
131
144
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.66.34",
4
+ "version": "1.66.36",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -83,6 +83,12 @@ interface SelfStatus {
83
83
  githubProxyManaged: boolean
84
84
  /** Whether the profile package manager owns this market installation. */
85
85
  selfManaged: boolean
86
+ /**
87
+ * Whether a remove request from this page could pass the market's own
88
+ * fence (#903). False behind a reverse proxy or a remote host, where the
89
+ * card must not offer a button that can only answer 403.
90
+ */
91
+ selfUninstallReachable: boolean
86
92
  /** The effective build environment (composition + card-saved override). */
87
93
  buildEnv: Record<string, string>
88
94
  }
@@ -91,6 +97,7 @@ interface SelfStatus {
91
97
  interface StatusBody {
92
98
  version?: string
93
99
  restart?: boolean
100
+ selfUninstallReachable?: boolean
94
101
  channel?: string
95
102
  channels?: string[]
96
103
  region?: string
@@ -168,6 +175,7 @@ function readStatus(body: StatusBody): SelfStatus {
168
175
  githubProxyCustom: typeof body.githubProxyCustom === 'string' ? body.githubProxyCustom : null,
169
176
  githubProxyManaged: body.githubProxyManaged === true,
170
177
  selfManaged: body.selfManaged !== false,
178
+ selfUninstallReachable: body.selfUninstallReachable !== false,
171
179
  buildEnv: body.buildEnv ?? {},
172
180
  }
173
181
  }
@@ -279,7 +287,7 @@ export function SettingsCard({ t, onRemoved, sidebar }: SettingsCardProps): Reac
279
287
  setStatus({
280
288
  version: null, restart: false, channel: 'stable', channels: ['stable', 'beta'],
281
289
  region: 'global', regions: REGIONS, regionAuto: false,
282
- githubProxyCustom: null, githubProxyManaged: false, selfManaged: true, buildEnv: {},
290
+ githubProxyCustom: null, githubProxyManaged: false, selfManaged: true, selfUninstallReachable: true, buildEnv: {},
283
291
  })
284
292
  }
285
293
  }
@@ -669,17 +677,18 @@ export function SettingsCard({ t, onRemoved, sidebar }: SettingsCardProps): Reac
669
677
  onClick: () => { void onSaveEnv() },
670
678
  }, t('setBuildEnvSave'))),
671
679
  ),
672
- status?.selfManaged === true ? row(t('setSelfRemove'), t('setSelfRemoveHint'),
673
- phase === 'confirming' || busy
674
- ? null
675
- : h(Button, {
680
+ status?.selfManaged === true ? row(t('setSelfRemove'),
681
+ status.selfUninstallReachable ? t('setSelfRemoveHint') : t('setSelfRemoveRemote'),
682
+ status.selfUninstallReachable && phase !== 'confirming' && !busy
683
+ ? h(Button, {
676
684
  variant: 'outline',
677
685
  size: 'sm',
678
686
  className: css.setDanger,
679
687
  disabled: busy,
680
688
  onClick: () => { setPhase('confirming') },
681
- }, t('setSelfRemove'))) : null,
682
- status?.selfManaged === true && (phase === 'confirming' || busy)
689
+ }, t('setSelfRemove'))
690
+ : null) : null,
691
+ status?.selfManaged === true && status.selfUninstallReachable && (phase === 'confirming' || busy)
683
692
  ? h('div', { className: css.setConfirm },
684
693
  h('div', { className: css.setHint }, t('setSelfConfirm')),
685
694
  h('label', { className: css.setCheck },
@@ -46,6 +46,7 @@ export const zh = {
46
46
  setChannelSwitchHint: '该版本低于当前已安装版本。',
47
47
  setSelfRemove: '卸载插件市场',
48
48
  setSelfRemoveHint: '从这个 profile 卸载市场。已装的其它插件不受影响。',
49
+ setSelfRemoveRemote: '这个页面不是从本机打开的,市场没法从这里卸载自己。请到运行 DSH 的那台机器上移除:桌面端用「设置 → 插件」,其它环境用命令行。',
49
50
  setSelfConfirm: '确定要卸载插件市场吗?卸载后需使用命令行重新安装。',
50
51
  setSelfPurge: '同时清除市场自己的数据',
51
52
  setSelfPurgeOn: '将删除:市场的停用清单与自定义分组,以及它写进配置文件的停用记录——被停用的插件将恢复运行。不涉及密码或密钥,市场从不把它们存在磁盘上。',
@@ -807,6 +808,7 @@ export const en: Record<MarketKey, string> = {
807
808
  setChannelSwitchHint: 'This version is lower than the one installed.',
808
809
  setSelfRemove: 'Uninstall the plugin market',
809
810
  setSelfRemoveHint: 'Uninstall the market from this profile. Your other plugins are untouched.',
811
+ setSelfRemoveRemote: 'This page was not opened on the machine running DSH, so the market cannot remove itself from here. Remove it there: Settings → Plugins on the desktop app, or the command line elsewhere.',
810
812
  setSelfConfirm: 'Uninstall the plugin market? Reinstalling it requires the command line.',
811
813
  setSelfPurge: 'Also clear the market\'s own data',
812
814
  setSelfPurgeOn: 'Will delete: the market\'s disable list and custom groups, plus the disable entries it wrote into your config file — plugins it switched off will run again. No passwords or tokens are involved; the market never stores those on disk.',
package/src/dsh-cli.ts CHANGED
@@ -500,6 +500,17 @@ export interface InstallResult {
500
500
  cancelled: boolean
501
501
  /** Desktop's generation-wide package-operation gate rejected the start. */
502
502
  busy?: boolean
503
+ /**
504
+ * The host refused before it ran the package manager, so the profile is
505
+ * exactly as it was.
506
+ *
507
+ * The update route answers a failed run by reinstalling the previous build
508
+ * and, when that cannot be verified, tells the user to inspect a profile
509
+ * nothing touched. Desktop's manager has refusals of this kind of its own —
510
+ * it declines a spec it considers already installed before pnpm starts
511
+ * (#897) — and their only true account is "nothing happened".
512
+ */
513
+ wroteNothing?: boolean
503
514
  /** Package names pnpm reported as having ignored build scripts (ndjson). */
504
515
  ignoredBuilds?: string[]
505
516
  /**
@@ -31,9 +31,74 @@ function detail(error: unknown): string {
31
31
  if (typeof error === 'string') return error
32
32
  if (error instanceof Error) return error.message
33
33
  if (error === undefined) return 'plugin manager did not explain the failure'
34
+ const code = managementCode(error)
35
+ const copy = code === undefined ? undefined : REFUSAL_COPY[code]
36
+ // The code stays on the line: it is what the log and the issue report carry.
37
+ if (copy !== undefined) return `${copy.zh} / ${copy.en} (${code})`
38
+ const message = (error as { message?: unknown }).message
39
+ if (typeof message === 'string' && message !== '') return message
34
40
  return JSON.stringify(error)
35
41
  }
36
42
 
43
+ /**
44
+ * What the manager's refusal means, in the user's terms.
45
+ *
46
+ * These codes are the manager's localization keys, not sentences; they used to
47
+ * reach the page as `{"code":"already-installed"}` and, on the update route,
48
+ * wrapped in "restoration of the previous build could not be verified" — alarm
49
+ * about a profile that was never touched (#897). The bilingual half is picked
50
+ * by the same `localizeBilingual` every other server message goes through.
51
+ */
52
+ const REFUSAL_COPY: Record<string, { zh: string; en: string }> = {
53
+ 'already-installed': {
54
+ zh: '桌面端认为这个包已经装在 profile 里,因此在运行 pnpm 之前就拒绝了这次安装,profile 没有任何改动',
55
+ en: 'the desktop app treats this package as already installed and refused the install before pnpm ran, so the profile is unchanged',
56
+ },
57
+ 'not-removable': {
58
+ zh: '这个插件由桌面端自己管理,市场不能移除它',
59
+ en: 'the desktop app manages this plugin itself, so the market cannot remove it',
60
+ },
61
+ 'bundle-in-use': {
62
+ zh: '这个插件正在使用中,桌面端不允许现在移除',
63
+ en: 'the plugin is in use, and the desktop app will not remove it right now',
64
+ },
65
+ 'stop-profile': {
66
+ zh: '要移除它得先停止这个 profile',
67
+ en: 'removing it requires stopping this profile first',
68
+ },
69
+ 'management-required': {
70
+ zh: '这个 profile 由桌面端自己管理,市场不能直接改动',
71
+ en: 'this profile is managed by the desktop app itself, so the market cannot change it',
72
+ },
73
+ 'incompatible-version': {
74
+ zh: '这个版本与当前 DSH 不兼容,宿主拒绝安装',
75
+ en: 'this version is not compatible with the running DSH, so the host refused it',
76
+ },
77
+ }
78
+
79
+ /**
80
+ * The market's own package names. A desktop profile installs it from npm as
81
+ * `dshmarket`; a fork or a local checkout can declare the repository's name.
82
+ */
83
+ const SELF_NAMES = new Set(['dshmarket', 'dsh-market'])
84
+
85
+ /**
86
+ * Why the market will not remove itself to update, and what to do instead.
87
+ *
88
+ * The manager's `removeBundle` drops the bundle from the running composition
89
+ * before pnpm runs. For every other package that is a normal unload; for the
90
+ * market it unloads the very code that was going to install the new version,
91
+ * so the reinstall never happens and the profile is left with the bundle row
92
+ * gone and the dependency still declared (#900). The app's own Plugins page
93
+ * performs the same two steps from outside the market and survives.
94
+ */
95
+ function selfUpdateMessage(spec: string): string {
96
+ const at = spec.indexOf('@', 1)
97
+ const version = at === -1 ? '' : spec.slice(at + 1)
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.`
100
+ }
101
+
37
102
  /** The manager's own refusal code, whether it was returned or thrown. */
38
103
  function managementCode(error: unknown): string | undefined {
39
104
  const code = (error as { code?: unknown } | null | undefined)?.code
@@ -76,6 +141,11 @@ async function installOrReinstall(
76
141
  const name = registryNameOf(spec)
77
142
  // A refusal we cannot turn into a name is the manager's answer to give.
78
143
  if (name === undefined) return first
144
+ // Removing the market would unload this very code before the reinstall runs
145
+ // (#900). Say how to update it instead.
146
+ if (SELF_NAMES.has(name)) {
147
+ return { application: 'failed', error: { code: 'market-self-update', message: selfUpdateMessage(spec) } }
148
+ }
79
149
  progress.lastLine = 'Removing the installed copy so the desktop app can install the new version'
80
150
  const removed = await manager.removeBundle(name)
81
151
  if (removed.application === 'failed' || removed.application === 'cancelled') return removed
@@ -189,11 +259,14 @@ export function createOfficialDesktopRuntime(
189
259
  stdout: output,
190
260
  stderr: message,
191
261
  cancelled: current.cancelled || result.application === 'cancelled',
262
+ // No package result means the manager stopped before it ran pnpm: the
263
+ // profile is untouched, and the route must not try to "restore" it.
264
+ wroteNothing: !ok && result.packageResult === undefined,
192
265
  }
193
266
  }, (error): InstallResult => {
194
267
  const message = detail(error)
195
268
  progress.error = message
196
- return failure(message, 127)
269
+ return { ...failure(message, 127), wroteNothing: managementCode(error) !== undefined }
197
270
  }).finally(() => {
198
271
  progress.active = false
199
272
  progress.cancelling = false
package/src/restart.ts CHANGED
@@ -233,6 +233,48 @@ export function restartReachableFrom(request: Pick<IncomingMessage, 'headers' |
233
233
  return directLoopbackRequest(request)
234
234
  }
235
235
 
236
+ /**
237
+ * Whether the request has the exact shape dsh Desktop's own document proxy
238
+ * forwards: a loopback peer, no forwarding headers, and **neither** `Host`
239
+ * nor `Origin`.
240
+ *
241
+ * The desktop app strips `origin`/`host` (and cookies) before forwarding to
242
+ * the internal host (#648), so that absence is the trusted desktop shape
243
+ * rather than a missing signal. It is also not something a browser can
244
+ * produce — every browser request carries `Host` — so a rebinding page or a
245
+ * cross-site POST still lands on the strict path below, carrying the
246
+ * attacker's own names (#678). A local non-browser client could omit both,
247
+ * but such a client can equally set them correctly, so this widens nothing
248
+ * against the threats the fence exists for.
249
+ */
250
+ function desktopForwardedRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
251
+ const address = request.socket.remoteAddress
252
+ if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
253
+ if (request.headers.forwarded !== undefined
254
+ || request.headers['x-forwarded-for'] !== undefined
255
+ || request.headers['x-real-ip'] !== undefined) return false
256
+ return request.headers.host === undefined && request.headers.origin === undefined
257
+ }
258
+
259
+ /**
260
+ * Whether a self-uninstall request may proceed.
261
+ *
262
+ * Same door as the restart route, plus the desktop proxy's headerless shape
263
+ * (#903): on Desktop the market's own card is the place users go to remove it,
264
+ * and the fence was refusing every such click because the proxy had stripped
265
+ * the very headers it required. Restart keeps the stricter predicate — it
266
+ * spawns a replacement host, which is not something a desktop-managed profile
267
+ * should get from here.
268
+ */
269
+ export function trustedSelfUninstallRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
270
+ return trustedRestartRequest(request) || desktopForwardedRequest(request)
271
+ }
272
+
273
+ /** Whether the request that asked for status could uninstall the market (#903). */
274
+ export function selfUninstallReachableFrom(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
275
+ return trustedSelfUninstallRequest(request)
276
+ }
277
+
236
278
  /** Whether a process-control request came from this Web host on loopback. */
237
279
  export function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
238
280
  if (!directLoopbackRequest(request)) return false
package/src/routes.ts CHANGED
@@ -55,7 +55,7 @@ import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, inval
55
55
  import { createThemeManager, type LoaderEntry } from './themes.ts'
56
56
  import { readJsonBody, refuseUnadmitted, sameOrigin, sendJson } from './http.ts'
57
57
  import { resolveDshHome } from './home-paths.ts'
58
- import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
58
+ import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, trustedSelfUninstallRequest, selfUninstallReachableFrom, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
59
59
  import type { RecoveryPlugin } from './recovery.ts'
60
60
  import { activationAfterReplace, brokenClientBundles, bundlePatchIsEmpty, checkClientBundle, defaultHostRuntimeFacts, findUnservedConfigNamespaces, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
61
61
  import {
@@ -3758,6 +3758,9 @@ export function mountMarketRoutes(
3758
3758
  // Deliberately NOT folded into `restart`, which is also what the
3759
3759
  // settings page reads and writes back as the user's own choice.
3760
3760
  restartReachable: restartReachableFrom(request),
3761
+ // Same door, one notch wider for the desktop proxy (#903): the
3762
+ // card hides a remove button that could only ever answer 403.
3763
+ selfUninstallReachable: selfUninstallReachableFrom(request),
3761
3764
  selfManaged: installed.dshmarket !== undefined || installed['dsh-market'] !== undefined,
3762
3765
  // Whether the host took the market's settings namespace (#677).
3763
3766
  // `unsupported-by-host` is 0.1.7 and newer, where settings come from
@@ -4808,7 +4811,13 @@ sendJson(response, 200, { updates })
4808
4811
  + (dropped ? '' : ' (nothing to drop: the profile did not declare it under either key)'))
4809
4812
  }
4810
4813
  if ((result.exitCode !== 0 || result.timedOut) && !cancelled && result.busy !== true
4811
- && !pnpmNeverStarted(result)) {
4814
+ && !pnpmNeverStarted(result)
4815
+ // The host refused before running pnpm (#897): nothing was
4816
+ // written, so there is nothing to restore — and the "could not
4817
+ // verify restoration, inspect this profile" warning this block
4818
+ // raises on a failed rollback would be alarm about an untouched
4819
+ // profile.
4820
+ && result.wroteNothing !== true) {
4812
4821
  if (pnpmBlockedByOpenFiles(result)) {
4813
4822
  const kept = keepLockedBuild()
4814
4823
  rollbackOk = kept.ok
@@ -5170,7 +5179,13 @@ sendJson(response, 200, { updates })
5170
5179
  // Last, so the specific ones above keep the floor they had: this
5171
5180
  // only fills the case where every named branch said nothing and
5172
5181
  // the user would otherwise get pnpm's stack (#848).
5173
- error: versionFailureError ?? renamedError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError ?? pnpmFailureMessage(result) ?? undefined,
5182
+ // The host's own refusal sentence (#897): when it stopped
5183
+ // before running pnpm there is no classifier for its words and
5184
+ // no rollback behind it, so without this the answer carried no
5185
+ // reason at all.
5186
+ error: versionFailureError ?? renamedError ?? trialError ?? brokenEntryError ?? hardFailureRollbackError ?? staleError
5187
+ ?? (result.wroteNothing === true && result.stderr.trim() !== '' ? result.stderr.trim() : undefined)
5188
+ ?? pnpmFailureMessage(result) ?? undefined,
5174
5189
  exitCode: result.exitCode,
5175
5190
  timedOut: result.timedOut,
5176
5191
  stdout: result.stdout,
@@ -5472,8 +5487,13 @@ sendJson(response, 200, { updates })
5472
5487
  // read as an extra guard while testing nothing: origin-matches-host
5473
5488
  // is already part of what this checks, so no request can fail one
5474
5489
  // and pass the other.
5475
- if (!trustedRestartRequest(request)) {
5476
- sendJson(response, 403, { error: 'self-uninstall is limited to same-origin loopback requests' })
5490
+ if (!trustedSelfUninstallRequest(request)) {
5491
+ // Says what to do, not only which policy refused it: this is the one
5492
+ // button whose failure leaves the user with no other way out of the
5493
+ // page they are looking at (#903).
5494
+ sendJson(response, 403, {
5495
+ error: `不能从这个地址卸载插件市场(只接受本机同源请求)。请在运行 DSH 的机器上执行 \`dsh plugin --profile ${config.profile} remove dshmarket\`;桌面端也可以在它自己的「设置 → 插件」里移除。 / The market can only be removed from the same machine it runs on. Run \`dsh plugin --profile ${config.profile} remove dshmarket\` there, or remove it from the desktop app's Settings → Plugins.`,
5496
+ })
5477
5497
  return
5478
5498
  }
5479
5499
  try {