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 +1 -1
- package/README.zh.md +1 -1
- package/client/client.js +6 -2
- package/lib/official-desktop.js +73 -1
- package/lib/restart.js +41 -0
- package/lib/routes.js +25 -5
- package/lib/types/dsh-cli.d.ts +11 -0
- package/lib/types/restart.d.ts +13 -0
- package/package.json +1 -1
- package/src/client/SettingsCard.tsx +16 -7
- package/src/client/locales.ts +2 -0
- package/src/dsh-cli.ts +11 -0
- package/src/official-desktop.ts +74 -1
- package/src/restart.ts +42 -0
- package/src/routes.ts +25 -5
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.
|
|
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
|
-
-
|
|
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
|
|
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: () => {
|
package/lib/official-desktop.js
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
5247
|
-
|
|
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 {
|
package/lib/types/dsh-cli.d.ts
CHANGED
|
@@ -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
|
/**
|
package/lib/types/restart.d.ts
CHANGED
|
@@ -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.
|
|
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'),
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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'))
|
|
682
|
-
|
|
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 },
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
/**
|
package/src/official-desktop.ts
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
5476
|
-
|
|
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 {
|