dshmarket 1.66.9 → 1.66.10
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/client/client.js +16 -2
- package/lib/check.js +31 -2
- package/lib/install.js +4 -0
- package/lib/pnpm-compat.js +23 -8
- package/lib/restart.js +32 -5
- package/lib/routes.js +41 -4
- package/lib/types/restart.d.ts +12 -0
- package/package.json +1 -1
- package/src/check.ts +31 -2
- package/src/client/MarketSection.tsx +9 -9
- package/src/client/locales.ts +2 -0
- package/src/client/market-data.ts +30 -0
- package/src/install.ts +4 -0
- package/src/pnpm-compat.ts +23 -8
- package/src/restart.ts +34 -5
- package/src/routes.ts +41 -4
package/client/client.js
CHANGED
|
@@ -326,6 +326,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
326
326
|
restartHint: "重启方式:关闭当前 dsh 进程后重新运行(例如 dsh web)",
|
|
327
327
|
restartHintSupervised: "本进程是 {0} 服务的主进程,重启交给它负责——市场自己重启会连带杀掉 cgroup 里的接管进程,服务将起不来。请执行 systemctl restart <你的 unit>。确认你的配置能承受市场自行重启(如 KillMode=process),可在本插件配置里手动打开「允许重启」。",
|
|
328
328
|
restartHintDebugged: "当前 dsh 进程正被调试器连接。请从 IDE 或终端停止该进程后重新启动,市场不能从界面里结束正在调试的 dsh。",
|
|
329
|
+
restartHintViaProxy: "你是通过代理或网关访问的,出于安全,重启只接受本机直连。请在运行 dsh 的机器上重启它,例如 docker restart 或 kubectl rollout restart。",
|
|
329
330
|
confirmTitle: "安装",
|
|
330
331
|
confirmWarn: "社区第三方插件,构建脚本默认不运行。",
|
|
331
332
|
installCaution: "安装前请注意",
|
|
@@ -1019,6 +1020,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1019
1020
|
restartHint: "To restart: stop the current dsh process and run it again (e.g. dsh web)",
|
|
1020
1021
|
restartHintSupervised: "This process is {0}'s own service process, so restarts belong to it — restarting from here would kill the takeover process along with the cgroup and the service would not come back. Use systemctl restart <your unit>. If your unit can survive a self-restart (KillMode=process), turn Allow restart on in this plugin's configuration.",
|
|
1021
1022
|
restartHintDebugged: "This dsh process is under a debugger. Stop it from your IDE or terminal and start it again — the market cannot stop the debugged process from the UI.",
|
|
1023
|
+
restartHintViaProxy: "You are reaching dsh through a proxy or gateway, and for safety restart only accepts a direct local connection. Restart it on the machine running dsh, for example with docker restart or kubectl rollout restart.",
|
|
1022
1024
|
confirmTitle: "Install",
|
|
1023
1025
|
confirmWarn: "Community third-party plugin. Build scripts do not run by default.",
|
|
1024
1026
|
installCaution: "Before you install",
|
|
@@ -1822,6 +1824,12 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
1822
1824
|
function isGenerationSpec(spec) {
|
|
1823
1825
|
return /^link:/i.test(spec) && /(?:^|[\\/])\.generations[\\/]live[\\/]/i.test(spec);
|
|
1824
1826
|
}
|
|
1827
|
+
function restartHintKey(state) {
|
|
1828
|
+
if (state.debuggerLatch !== null) return "restartHintDebugged";
|
|
1829
|
+
if (!state.restartReachable) return "restartHintViaProxy";
|
|
1830
|
+
if (state.supervisor !== null) return "restartHintSupervised";
|
|
1831
|
+
return "restartHint";
|
|
1832
|
+
}
|
|
1825
1833
|
function groupSwitchState(members, disabled) {
|
|
1826
1834
|
const list = members ?? [];
|
|
1827
1835
|
if (list.length === 0) return "empty";
|
|
@@ -8365,6 +8373,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8365
8373
|
const [supervisor, setSupervisor] = (0, react.useState)(null);
|
|
8366
8374
|
/** Debugger latch when one-click restart must not kill the host (#447). */
|
|
8367
8375
|
const [debuggerLatch, setDebuggerLatch] = (0, react.useState)(null);
|
|
8376
|
+
const [restartReachable, setRestartReachable] = (0, react.useState)(true);
|
|
8368
8377
|
const [restarting, setRestarting] = (0, react.useState)(false);
|
|
8369
8378
|
const [showTop, setShowTop] = (0, react.useState)(false);
|
|
8370
8379
|
const [backupBusy, setBackupBusy] = (0, react.useState)(false);
|
|
@@ -8622,6 +8631,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
8622
8631
|
setRestartEnabled(status.restart === true);
|
|
8623
8632
|
setSupervisor(typeof status.supervisor === "string" ? status.supervisor : null);
|
|
8624
8633
|
setDebuggerLatch(typeof status.debugger === "string" ? status.debugger : null);
|
|
8634
|
+
setRestartReachable(status.restartReachable !== false);
|
|
8625
8635
|
if (typeof status.version === "string" && status.version !== "") setVersion(status.version);
|
|
8626
8636
|
}).catch(() => {});
|
|
8627
8637
|
refreshInstalled();
|
|
@@ -11828,14 +11838,18 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
|
|
|
11828
11838
|
]
|
|
11829
11839
|
}),
|
|
11830
11840
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
11831
|
-
label:
|
|
11841
|
+
label: t(restartHintKey({
|
|
11842
|
+
debuggerLatch,
|
|
11843
|
+
restartReachable,
|
|
11844
|
+
supervisor
|
|
11845
|
+
})).replace("{0}", supervisor ?? ""),
|
|
11832
11846
|
side: "bottom",
|
|
11833
11847
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
11834
11848
|
className: Market_module_css_default.bannerHint,
|
|
11835
11849
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(IconQuestionOutline14, { size: 14 })
|
|
11836
11850
|
})
|
|
11837
11851
|
}),
|
|
11838
|
-
restartEnabled && debuggerLatch === null && recovery === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
11852
|
+
restartEnabled && restartReachable && debuggerLatch === null && recovery === null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
11839
11853
|
variant: "primary",
|
|
11840
11854
|
size: "sm",
|
|
11841
11855
|
disabled: restarting || hostBusy || busyUrl !== null || updatingName !== null || removingName !== null,
|
package/lib/check.js
CHANGED
|
@@ -31,6 +31,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
|
31
31
|
import { JSON_SCHEMA, Type, load } from 'js-yaml';
|
|
32
32
|
import { desktopApplicationRoots, findDshInstallDir } from './dsh-install.js';
|
|
33
33
|
import { resolveDshHome } from './home-paths.js';
|
|
34
|
+
import { hostNodeModulesRoot } from './install.js';
|
|
34
35
|
import { INBOX_BUNDLES, readBundleRules, suggestOrder, validateOrder } from './order.js';
|
|
35
36
|
// Electron's app.asar packages can be loadable by the host while invisible to
|
|
36
37
|
// filesystem probes made from a profile plugin. This is a property of the
|
|
@@ -182,7 +183,20 @@ export function corePackageNames(dshInstallDir) {
|
|
|
182
183
|
// The install's own node_modules is the authoritative host-core inventory:
|
|
183
184
|
// everything under @deepseek-ai whose bare name starts with dsh or cordis,
|
|
184
185
|
// plus the app package itself.
|
|
185
|
-
|
|
186
|
+
//
|
|
187
|
+
// `dshHostInfo()` answers either a PACKAGE directory (the CLI layout
|
|
188
|
+
// `<prefix>/node_modules/@deepseek-ai/dsh`, and the #778 nested form
|
|
189
|
+
// `<resources>/app.asar/dsh/node_modules/@deepseek-ai/dsh`) or a flat
|
|
190
|
+
// Desktop deployment root — never a constant "installation root".
|
|
191
|
+
// Splicing `node_modules` onto whichever arrived only happened to work
|
|
192
|
+
// for the flat shape. For a package directory the splice landed two
|
|
193
|
+
// levels deep and readdirSync threw, so the curated seed silently stood
|
|
194
|
+
// in for the real inventory — and when the host package carries its own
|
|
195
|
+
// nested `@deepseek-ai` scope (pnpm isolated trees), it read that
|
|
196
|
+
// truncated subset instead (#676: every package the real inventory has
|
|
197
|
+
// and the answer lacked became a fresh "will fail to boot").
|
|
198
|
+
// hostNodeModulesRoot() already derives the shared root from both shapes.
|
|
199
|
+
for (const entry of readdirSync(join(hostNodeModulesRoot(dshInstallDir), '@deepseek-ai'), { withFileTypes: true })) {
|
|
186
200
|
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
187
201
|
continue;
|
|
188
202
|
if (/^(?:dsh|cordis)/.test(entry.name))
|
|
@@ -1128,7 +1142,22 @@ export function analyzeProfile(profileDirectory, options = {}) {
|
|
|
1128
1142
|
? readNodeModulesVersion(profileDirectory, name)
|
|
1129
1143
|
: readProfileVisibleVersion(profileDirectory, name);
|
|
1130
1144
|
const nested = readNodeModulesVersion(pluginDir, name);
|
|
1131
|
-
|
|
1145
|
+
// The located installation is asked as `host` here — and `dshInstall` is
|
|
1146
|
+
// the PACKAGE directory `dshHostInfo()` answers (the CLI layout
|
|
1147
|
+
// `<prefix>/node_modules/@deepseek-ai/dsh`, and the #778 nested form
|
|
1148
|
+
// `<resources>/app.asar/dsh/node_modules/@deepseek-ai/dsh`), or a flat
|
|
1149
|
+
// Desktop deployment root. `readNodeModulesVersion` takes a package root
|
|
1150
|
+
// and appends `node_modules` itself, so passing a package directory read
|
|
1151
|
+
// `<package>/node_modules/<name>`: absent in most layouts, which silently
|
|
1152
|
+
// left the located installation unasked while the comment above promises
|
|
1153
|
+
// it is asked; and where the host package carries its own nested
|
|
1154
|
+
// `@deepseek-ai` scope (pnpm isolated trees) it answered from that
|
|
1155
|
+
// truncated subset instead — the opposite of the "unknown rather than
|
|
1156
|
+
// wrong" this rule promises. `hostNodeModulesRoot()` derives the shared
|
|
1157
|
+
// root from both shapes (#804's fix, same arithmetic).
|
|
1158
|
+
const host = dshInstall !== null
|
|
1159
|
+
? readNodeModulesVersion(dirname(hostNodeModulesRoot(dshInstall)), name)
|
|
1160
|
+
: null;
|
|
1132
1161
|
// Node resolves a plugin's peer from its OWN node_modules first
|
|
1133
1162
|
// (nested), then the profile tree (hoisted), then the host install.
|
|
1134
1163
|
const resolved = nested ?? hoisted ?? host;
|
package/lib/install.js
CHANGED
|
@@ -209,6 +209,10 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
|
|
|
209
209
|
// SAME argv succeeds — no option is needed, which also means this works
|
|
210
210
|
// on the desktop bridge that refuses market options (#732).
|
|
211
211
|
//
|
|
212
|
+
// Covers both lockfiles pnpm writes through the same no-retry atomic
|
|
213
|
+
// rename: the profile's `pnpm-lock.yaml` and the one inside
|
|
214
|
+
// `node_modules/.pnpm/lock.yaml`.
|
|
215
|
+
//
|
|
212
216
|
// Retried here rather than in the route because `pnpmBlockedByOpenFiles`
|
|
213
217
|
// and the route's rollback both read the FINAL result: leaving this
|
|
214
218
|
// unretried made the route treat a transient lock as "the running host
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -456,27 +456,42 @@ export function classifyPnpmFailure(output, exitCode) {
|
|
|
456
456
|
: `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
|
|
457
457
|
};
|
|
458
458
|
}
|
|
459
|
-
// #786 follow-up: pnpm could not replace one of the
|
|
460
|
-
// `package.json
|
|
459
|
+
// #786 follow-up: pnpm could not replace one of the profile's own files —
|
|
460
|
+
// `package.json`, `pnpm-lock.yaml` — or the lockfile it keeps INSIDE
|
|
461
|
+
// `node_modules/.pnpm/lock.yaml`. None of these is a package directory.
|
|
461
462
|
//
|
|
462
|
-
// pnpm writes
|
|
463
|
-
// rename and no retry (measured on the bundled 11.7.0: the temp name is
|
|
463
|
+
// pnpm writes all of them through its bundled `write-file-atomic`, which does
|
|
464
|
+
// ONE rename and no retry (measured on the bundled 11.7.0: the temp name is
|
|
464
465
|
// `<file>.<hash>`, e.g. `pnpm-lock.yaml.3015012533`). Any momentary holder —
|
|
465
466
|
// Defender scanning the just-written file, the Windows indexer, an editor —
|
|
466
467
|
// fails the entire run. Measured on the reporter's own profile: 4 identical
|
|
467
468
|
// `add` runs, the 4th failed this way and an immediate 5th succeeded.
|
|
468
469
|
//
|
|
470
|
+
// The `.pnpm/lock.yaml` shape is the SAME failure, not a lesser one: pnpm's
|
|
471
|
+
// `writeLockfiles` writes the wanted lockfile and the current one through the
|
|
472
|
+
// same write-file-atomic inside one `Promise.all` (pnpm 11.7.0, both the
|
|
473
|
+
// equal-lockfile and the differing one branch). So a lock on the temp name
|
|
474
|
+
// `…\.pnpm\lock.yaml.<hash>` fails the run exactly like a lock on
|
|
475
|
+
// `pnpm-lock.yaml` — and answering it as the package-directory case below
|
|
476
|
+
// would roll the profile's lockfile back over a `node_modules` that already
|
|
477
|
+
// holds the new build, which is the desync this whole branch exists to stop.
|
|
478
|
+
//
|
|
469
479
|
// This must be answered BEFORE the package-directory branch below, whose
|
|
470
480
|
// pattern is broad enough to swallow it: that branch blames the running host
|
|
471
481
|
// for holding the plugin's files open and tells the user to quit DSH, which
|
|
472
482
|
// is wrong here (nothing about the plugin is locked) and is the most
|
|
473
483
|
// expensive advice available for a failure that a plain retry clears.
|
|
474
|
-
//
|
|
475
|
-
|
|
484
|
+
//
|
|
485
|
+
// `recoverable: false`, like the other same-argv retries (`transient-network`,
|
|
486
|
+
// `fetch-timeout`): that flag means "re-running `pnpm install` is the
|
|
487
|
+
// documented recovery", and no code reads it to decide a retry — the retry is
|
|
488
|
+
// keyed on this code in `withHoistRecovery`. Marking it true would invite a
|
|
489
|
+
// future reader to rebuild `node_modules` over a one-file rename.
|
|
490
|
+
if (/(?:ERR_PNPM_)?EPERM[^\n]*rename[^\n]*(?:[\\/](?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)\.\d+|[\\/]\.pnpm[\\/]lock\.yaml\.\d+)/i.test(withDecodedPnpmDiagnostics(output))) {
|
|
476
491
|
return {
|
|
477
492
|
code: 'profile-file-locked',
|
|
478
|
-
recoverable:
|
|
479
|
-
message: 'pnpm
|
|
493
|
+
recoverable: false,
|
|
494
|
+
message: 'pnpm 写入 package.json 或 pnpm-lock.yaml 时被 Windows 拒绝了一下——通常是杀毒软件或索引服务正好在读这个刚写完的文件。插件本身没有被占用;市场已经自动重试过一次,还是失败,请再试一次 / Windows briefly refused pnpm\'s write of package.json or pnpm-lock.yaml — usually antivirus or the search indexer reading the file pnpm had just written. Nothing about the plugin is locked; the market already retried once and it still failed, so try again',
|
|
480
495
|
};
|
|
481
496
|
}
|
|
482
497
|
// #389 by @qq1054435284: on Windows, pnpm stages the new version in a
|
package/lib/restart.js
CHANGED
|
@@ -174,8 +174,17 @@ export function servingPort(request) {
|
|
|
174
174
|
const port = Number(match[1]);
|
|
175
175
|
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null;
|
|
176
176
|
}
|
|
177
|
-
/**
|
|
178
|
-
|
|
177
|
+
/**
|
|
178
|
+
* The part of the restart fence that does not depend on `Origin`: a loopback
|
|
179
|
+
* peer, no forwarding trace, and a Host that names a loopback authority.
|
|
180
|
+
*
|
|
181
|
+
* One function so the route that ENFORCES the fence and the status poll that
|
|
182
|
+
* PREDICTS it cannot drift apart (#782). It deliberately leaves `Origin` out:
|
|
183
|
+
* a same-origin GET — which is what the status poll is — carries none, so a
|
|
184
|
+
* prediction that required it would call every ordinary local request
|
|
185
|
+
* impossible and take the button away from everyone.
|
|
186
|
+
*/
|
|
187
|
+
function directLoopbackRequest(request) {
|
|
179
188
|
const address = request.socket.remoteAddress;
|
|
180
189
|
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1')
|
|
181
190
|
return false;
|
|
@@ -184,14 +193,32 @@ export function trustedRestartRequest(request) {
|
|
|
184
193
|
|| request.headers['x-forwarded-for'] !== undefined
|
|
185
194
|
|| request.headers['x-real-ip'] !== undefined)
|
|
186
195
|
return false;
|
|
187
|
-
const origin = request.headers.origin;
|
|
188
|
-
const host = request.headers.host;
|
|
189
196
|
// A loopback PEER is not enough: a DNS-rebinding page reaches 127.0.0.1
|
|
190
197
|
// through a name it controls, so the address proves nothing and Host/Origin
|
|
191
198
|
// both carry the attacker's domain (#678). Host is what the attack cannot
|
|
192
199
|
// forge, so it has to name a loopback authority.
|
|
193
|
-
|
|
200
|
+
return loopbackAuthority(request.headers.host);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Whether a restart request made from the same page as THIS request could pass
|
|
204
|
+
* the fence (#782): the answer the status poll reports so the banner can say
|
|
205
|
+
* why there is no button, instead of rendering one that always answers 403.
|
|
206
|
+
*
|
|
207
|
+
* Read off the request that asked for status, not off configuration. Behind an
|
|
208
|
+
* Ingress or any reverse proxy the restart POST arrives from the same page over
|
|
209
|
+
* the same path, so it fails for the same reasons this request shows — a proxy
|
|
210
|
+
* peer, forwarding headers, a non-loopback Host. It is a prediction, never a
|
|
211
|
+
* grant: the route still runs the full fence, including `Origin`, on the POST.
|
|
212
|
+
*/
|
|
213
|
+
export function restartReachableFrom(request) {
|
|
214
|
+
return directLoopbackRequest(request);
|
|
215
|
+
}
|
|
216
|
+
/** Whether a process-control request came from this Web host on loopback. */
|
|
217
|
+
export function trustedRestartRequest(request) {
|
|
218
|
+
if (!directLoopbackRequest(request))
|
|
194
219
|
return false;
|
|
220
|
+
const origin = request.headers.origin;
|
|
221
|
+
const host = request.headers.host;
|
|
195
222
|
if (origin === undefined || host === undefined)
|
|
196
223
|
return false;
|
|
197
224
|
try {
|
package/lib/routes.js
CHANGED
|
@@ -41,7 +41,7 @@ import { updateNotesFor } from './changelog.js';
|
|
|
41
41
|
import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, invalidateUpdates, resolveGitRemoteHead, isUpgrade, latestPublishedRecently, setUpdateRegistry, versionOnChannel } from './updates.js';
|
|
42
42
|
import { createThemeManager } from './themes.js';
|
|
43
43
|
import { readJsonBody, sameOrigin, sendJson } from './http.js';
|
|
44
|
-
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest } from './restart.js';
|
|
44
|
+
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, restartReachableFrom } from './restart.js';
|
|
45
45
|
import { activationAfterReplace, brokenClientBundles, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.js';
|
|
46
46
|
import { carrierDisableIds, disableRow, enableRow, findUserPatchPath, foreignRowIds, isProtectedModule, packagePatchFlags, readUserPatchState, removeRowBlocks, rowIdsForPackage, userPatchPackageReferences, } from './patch.js';
|
|
47
47
|
import { createProfileBackup, downloadWebdav, MAX_BACKUP_BYTES, mergeRestoreManifest, restoreProfileBackup, unportableDeps, uploadWebdav, } from './backup.js';
|
|
@@ -1114,6 +1114,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1114
1114
|
// and the page then tells the user to refresh, which is the one thing
|
|
1115
1115
|
// that cannot help.
|
|
1116
1116
|
const native = holdsNativeAddon(config.profile, name, activeProfileDir);
|
|
1117
|
+
// Asked BEFORE the removal, like `native` above and for the same reason:
|
|
1118
|
+
// the row ids come from the package's own bundle patch, which the remove
|
|
1119
|
+
// deletes. Read afterwards this returns [] and the rows survive the
|
|
1120
|
+
// package as boot-time orphans (#799).
|
|
1121
|
+
const ownedRows = rowIdsForPackage(host, activeProfileDir, name);
|
|
1117
1122
|
const result = await runPlugin(config.profile, ['remove', name]);
|
|
1118
1123
|
if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
|
|
1119
1124
|
return { ok: false, hot: false, detail: failureDetail(result) };
|
|
@@ -1141,7 +1146,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
1141
1146
|
if (native) {
|
|
1142
1147
|
logEvent('info', 'uninstall', `${name} ships or depends on a native addon; a restart is needed before it can be installed again`);
|
|
1143
1148
|
}
|
|
1144
|
-
removeRowBlocks(userPatchPath,
|
|
1149
|
+
removeRowBlocks(userPatchPath, ownedRows);
|
|
1145
1150
|
disabled.delete(name);
|
|
1146
1151
|
replacedWhileLive.delete(name);
|
|
1147
1152
|
removeFromGroups({ groups, groupOrder }, name);
|
|
@@ -3199,6 +3204,15 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
3199
3204
|
// "no restart button" is the state #229 reported as broken.
|
|
3200
3205
|
supervisor: detectedSupervisor(),
|
|
3201
3206
|
debugger: detectedDebugger(),
|
|
3207
|
+
// Whether a restart asked for from THIS page could pass the route's
|
|
3208
|
+
// fence (#782). Per request, not per config: behind a reverse proxy
|
|
3209
|
+
// the POST arrives over the same path as this GET, so it fails for
|
|
3210
|
+
// the same reasons — and the banner used to render a button that
|
|
3211
|
+
// answered 403 every time, with nothing saying so in advance. It is
|
|
3212
|
+
// a prediction: the route still runs the whole fence on the POST.
|
|
3213
|
+
// Deliberately NOT folded into `restart`, which is also what the
|
|
3214
|
+
// settings page reads and writes back as the user's own choice.
|
|
3215
|
+
restartReachable: restartReachableFrom(request),
|
|
3202
3216
|
selfManaged: installed.dshmarket !== undefined || installed['dsh-market'] !== undefined,
|
|
3203
3217
|
// Whether the host took the market's settings namespace (#677).
|
|
3204
3218
|
// `unsupported-by-host` is 0.1.7 and newer, where settings come from
|
|
@@ -4067,6 +4081,21 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4067
4081
|
// declaration is unchanged; a failed run restores the manifest from
|
|
4068
4082
|
// the snapshot above.
|
|
4069
4083
|
//
|
|
4084
|
+
// `reresolveInPlace` is not the only shape whose diff is empty.
|
|
4085
|
+
// When the manifest holds the spelling pnpm itself wrote for a
|
|
4086
|
+
// GitHub source — `git+https://owner/repo.git` — and the market
|
|
4087
|
+
// sends the `github:owner/repo` shortcut, the target differs from
|
|
4088
|
+
// the specifier but pnpm still gives the specifier back unchanged,
|
|
4089
|
+
// so the host answers `ambiguous-install` again (#803; measured on
|
|
4090
|
+
// pnpm 11.7.0, the version DSH Desktop bundles, against the
|
|
4091
|
+
// reporter's own spec pair). One question covers both: pnpm leaves
|
|
4092
|
+
// the specifier as it was, so the diff the host reads is empty.
|
|
4093
|
+
//
|
|
4094
|
+
// The pin is the market's own write to a foreign pipeline, so it is
|
|
4095
|
+
// scoped to the host that reads the manifest itself. A host that
|
|
4096
|
+
// takes market options lets the market verify its own run, and gets
|
|
4097
|
+
// no extra manifest write.
|
|
4098
|
+
//
|
|
4070
4099
|
// A `#path:` spec needs the local pin instead of the host's exact
|
|
4071
4100
|
// rollback target: `gitTargetAtCommit` refuses the `&` that carries
|
|
4072
4101
|
// a selector beside a commit, because dsh-cli's target grammar has
|
|
@@ -4076,7 +4105,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
4076
4105
|
const pinValue = gitRollbackTarget ?? (hasGitSubpath && beforeCommit !== null
|
|
4077
4106
|
? pinSpecToCommit(spec, beforeCommit)
|
|
4078
4107
|
: null);
|
|
4079
|
-
const
|
|
4108
|
+
const identifiesInstalledByManifestDiff = !marketFlags;
|
|
4109
|
+
const pinnedForIdentification = isGit && !restore
|
|
4110
|
+
&& identifiesInstalledByManifestDiff
|
|
4080
4111
|
&& pinValue !== null && pinValue !== spec;
|
|
4081
4112
|
if (pinnedForIdentification) {
|
|
4082
4113
|
declareProfileDependency(config.profile, name, pinValue, activeProfileDir);
|
|
@@ -5214,6 +5245,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
5214
5245
|
// `hot` would send the user to a page refresh, which is the one
|
|
5215
5246
|
// thing that cannot help.
|
|
5216
5247
|
const heldNativeAddon = holdsNativeAddon(config.profile, name, activeProfileDir);
|
|
5248
|
+
// And once more, for the same reason: `rowIdsForPackage` names the
|
|
5249
|
+
// rows from the package's own bundle patch, which the remove below
|
|
5250
|
+
// deletes. Asked afterwards it answers [] on exactly the uninstall
|
|
5251
|
+
// it is meant to clean up after, and the rows outlive the package
|
|
5252
|
+
// as boot-time orphans (#799).
|
|
5253
|
+
const ownedRows = rowIdsForPackage(host, activeProfileDir, name);
|
|
5217
5254
|
const result = await runPlugin(config.profile, ['remove', name]);
|
|
5218
5255
|
const cancelled = result.cancelled;
|
|
5219
5256
|
const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
|
|
@@ -5277,7 +5314,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
|
|
|
5277
5314
|
// Patch-layer rows must not survive the remove either: a
|
|
5278
5315
|
// `- id: X` + `disabled: true` row for a package that no longer
|
|
5279
5316
|
// mounts is a boot-time orphan (port of dsh-plugin-hub).
|
|
5280
|
-
removeRowBlocks(userPatchPath,
|
|
5317
|
+
removeRowBlocks(userPatchPath, ownedRows);
|
|
5281
5318
|
// The disable list must not keep a removed plugin: a later
|
|
5282
5319
|
// reinstall starts enabled. Group memberships follow the same
|
|
5283
5320
|
// rule so no group toggle ever targets a ghost member.
|
package/lib/types/restart.d.ts
CHANGED
|
@@ -114,6 +114,18 @@ export declare function restartAllowed(config: {
|
|
|
114
114
|
* @returns the port, or null when the header carries none (a default port).
|
|
115
115
|
*/
|
|
116
116
|
export declare function servingPort(request: Pick<IncomingMessage, 'headers'>): number | null;
|
|
117
|
+
/**
|
|
118
|
+
* Whether a restart request made from the same page as THIS request could pass
|
|
119
|
+
* the fence (#782): the answer the status poll reports so the banner can say
|
|
120
|
+
* why there is no button, instead of rendering one that always answers 403.
|
|
121
|
+
*
|
|
122
|
+
* Read off the request that asked for status, not off configuration. Behind an
|
|
123
|
+
* Ingress or any reverse proxy the restart POST arrives from the same page over
|
|
124
|
+
* the same path, so it fails for the same reasons this request shows — a proxy
|
|
125
|
+
* peer, forwarding headers, a non-loopback Host. It is a prediction, never a
|
|
126
|
+
* grant: the route still runs the full fence, including `Origin`, on the POST.
|
|
127
|
+
*/
|
|
128
|
+
export declare function restartReachableFrom(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
|
|
117
129
|
/** Whether a process-control request came from this Web host on loopback. */
|
|
118
130
|
export declare function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean;
|
|
119
131
|
/**
|
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.10",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/types/index.d.ts",
|
package/src/check.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
|
32
32
|
import { JSON_SCHEMA, Type, load } from 'js-yaml'
|
|
33
33
|
import { desktopApplicationRoots, findDshInstallDir } from './dsh-install.ts'
|
|
34
34
|
import { resolveDshHome } from './home-paths.ts'
|
|
35
|
+
import { hostNodeModulesRoot } from './install.ts'
|
|
35
36
|
import { INBOX_BUNDLES, readBundleRules, suggestOrder, validateOrder } from './order.ts'
|
|
36
37
|
|
|
37
38
|
// Electron's app.asar packages can be loadable by the host while invisible to
|
|
@@ -381,7 +382,20 @@ export function corePackageNames(dshInstallDir: string | null): Set<string> {
|
|
|
381
382
|
// The install's own node_modules is the authoritative host-core inventory:
|
|
382
383
|
// everything under @deepseek-ai whose bare name starts with dsh or cordis,
|
|
383
384
|
// plus the app package itself.
|
|
384
|
-
|
|
385
|
+
//
|
|
386
|
+
// `dshHostInfo()` answers either a PACKAGE directory (the CLI layout
|
|
387
|
+
// `<prefix>/node_modules/@deepseek-ai/dsh`, and the #778 nested form
|
|
388
|
+
// `<resources>/app.asar/dsh/node_modules/@deepseek-ai/dsh`) or a flat
|
|
389
|
+
// Desktop deployment root — never a constant "installation root".
|
|
390
|
+
// Splicing `node_modules` onto whichever arrived only happened to work
|
|
391
|
+
// for the flat shape. For a package directory the splice landed two
|
|
392
|
+
// levels deep and readdirSync threw, so the curated seed silently stood
|
|
393
|
+
// in for the real inventory — and when the host package carries its own
|
|
394
|
+
// nested `@deepseek-ai` scope (pnpm isolated trees), it read that
|
|
395
|
+
// truncated subset instead (#676: every package the real inventory has
|
|
396
|
+
// and the answer lacked became a fresh "will fail to boot").
|
|
397
|
+
// hostNodeModulesRoot() already derives the shared root from both shapes.
|
|
398
|
+
for (const entry of readdirSync(join(hostNodeModulesRoot(dshInstallDir), '@deepseek-ai'), { withFileTypes: true })) {
|
|
385
399
|
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
386
400
|
if (/^(?:dsh|cordis)/.test(entry.name)) names.add(`@deepseek-ai/${entry.name}`)
|
|
387
401
|
}
|
|
@@ -1346,7 +1360,22 @@ export function analyzeProfile(profileDirectory: string, options: CheckOptions =
|
|
|
1346
1360
|
? readNodeModulesVersion(profileDirectory, name)
|
|
1347
1361
|
: readProfileVisibleVersion(profileDirectory, name)
|
|
1348
1362
|
const nested = readNodeModulesVersion(pluginDir, name)
|
|
1349
|
-
|
|
1363
|
+
// The located installation is asked as `host` here — and `dshInstall` is
|
|
1364
|
+
// the PACKAGE directory `dshHostInfo()` answers (the CLI layout
|
|
1365
|
+
// `<prefix>/node_modules/@deepseek-ai/dsh`, and the #778 nested form
|
|
1366
|
+
// `<resources>/app.asar/dsh/node_modules/@deepseek-ai/dsh`), or a flat
|
|
1367
|
+
// Desktop deployment root. `readNodeModulesVersion` takes a package root
|
|
1368
|
+
// and appends `node_modules` itself, so passing a package directory read
|
|
1369
|
+
// `<package>/node_modules/<name>`: absent in most layouts, which silently
|
|
1370
|
+
// left the located installation unasked while the comment above promises
|
|
1371
|
+
// it is asked; and where the host package carries its own nested
|
|
1372
|
+
// `@deepseek-ai` scope (pnpm isolated trees) it answered from that
|
|
1373
|
+
// truncated subset instead — the opposite of the "unknown rather than
|
|
1374
|
+
// wrong" this rule promises. `hostNodeModulesRoot()` derives the shared
|
|
1375
|
+
// root from both shapes (#804's fix, same arithmetic).
|
|
1376
|
+
const host = dshInstall !== null
|
|
1377
|
+
? readNodeModulesVersion(dirname(hostNodeModulesRoot(dshInstall)), name)
|
|
1378
|
+
: null
|
|
1350
1379
|
// Node resolves a plugin's peer from its OWN node_modules first
|
|
1351
1380
|
// (nested), then the profile tree (hoisted), then the host install.
|
|
1352
1381
|
const resolved = nested ?? hoisted ?? host
|
|
@@ -51,7 +51,7 @@ import { Diagnostics } from './Diagnostics.tsx'
|
|
|
51
51
|
import { exportMarketLog } from './self-check.ts'
|
|
52
52
|
import {
|
|
53
53
|
api, applyGithubRouting, avatarColor, catalogEntryForInstalled, entryForDep, githubRouteCandidates, groupSwitchState, humanOutput, installedForCatalog, isGenerationSpec, isInstalled, localizeBilingual, localizeBilingualList, looksTerminal, matchInstalledName, orderedCategories, pluginCategories,
|
|
54
|
-
formatCount, pageItems, pluginName, blockAliases, pluginScreenshotCandidates, pluginScreenshots, pluginsForFavorites, queuedRowApplies, rankThemeScreenshots, readSession, releaseNotesHttpsImage, rememberGithubRoute, resetScreenshotsCache, resolveCatalogRestore, safeScreenshots, sanitizeReleaseNotesBody, staleFavoriteUrls, themePlugins as themePluginsOf, themeSwatch, TIME_RANGE_DAYS, visiblePlugins,
|
|
54
|
+
formatCount, pageItems, pluginName, blockAliases, pluginScreenshotCandidates, pluginScreenshots, pluginsForFavorites, queuedRowApplies, rankThemeScreenshots, readSession, releaseNotesHttpsImage, rememberGithubRoute, resetScreenshotsCache, resolveCatalogRestore, safeScreenshots, sanitizeReleaseNotesBody, staleFavoriteUrls, themePlugins as themePluginsOf, themeSwatch, TIME_RANGE_DAYS, visiblePlugins, restartHintKey,
|
|
55
55
|
} from './market-data.ts'
|
|
56
56
|
import type {
|
|
57
57
|
ActivationInfo, ActivationState, GistExportResult, InstalledMap, InstalledRepoHints, InstalledRepoIdentities, MarketStatus, Registry, RegistryPlugin,
|
|
@@ -2236,6 +2236,9 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2236
2236
|
const [supervisor, setSupervisor] = useState<string | null>(null)
|
|
2237
2237
|
/** Debugger latch when one-click restart must not kill the host (#447). */
|
|
2238
2238
|
const [debuggerLatch, setDebuggerLatch] = useState<string | null>(null)
|
|
2239
|
+
// False when this page reaches dsh through a proxy: a restart from here is refused
|
|
2240
|
+
// every time, so the banner says so instead of offering a button (#782).
|
|
2241
|
+
const [restartReachable, setRestartReachable] = useState(true)
|
|
2239
2242
|
const [restarting, setRestarting] = useState(false)
|
|
2240
2243
|
const [showTop, setShowTop] = useState(false)
|
|
2241
2244
|
const [backupBusy, setBackupBusy] = useState(false)
|
|
@@ -2533,6 +2536,9 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
2533
2536
|
setRestartEnabled(status.restart === true)
|
|
2534
2537
|
setSupervisor(typeof status.supervisor === 'string' ? status.supervisor : null)
|
|
2535
2538
|
setDebuggerLatch(typeof status.debugger === 'string' ? status.debugger : null)
|
|
2539
|
+
// Only an explicit false takes the button away. Absent (an older host) or
|
|
2540
|
+
// anything else keeps it: never remove an action on a guess.
|
|
2541
|
+
setRestartReachable(status.restartReachable !== false)
|
|
2536
2542
|
if (typeof status.version === 'string' && status.version !== '') setVersion(status.version)
|
|
2537
2543
|
})
|
|
2538
2544
|
.catch(() => {})
|
|
@@ -5674,18 +5680,12 @@ export function MarketSection(props: MarketSectionProps) {
|
|
|
5674
5680
|
<IconRefreshOutline14 size={14} className={css.bannerIcon} />
|
|
5675
5681
|
<span className={css.grow}><b>{pendingRestart}</b> {t('restartBanner')}</span>
|
|
5676
5682
|
<Tooltip
|
|
5677
|
-
label={
|
|
5678
|
-
debuggerLatch !== null
|
|
5679
|
-
? t('restartHintDebugged')
|
|
5680
|
-
: supervisor === null
|
|
5681
|
-
? t('restartHint')
|
|
5682
|
-
: t('restartHintSupervised').replace('{0}', supervisor)
|
|
5683
|
-
}
|
|
5683
|
+
label={t(restartHintKey({ debuggerLatch, restartReachable, supervisor })).replace('{0}', supervisor ?? '')}
|
|
5684
5684
|
side="bottom"
|
|
5685
5685
|
>
|
|
5686
5686
|
<span className={css.bannerHint}><IconQuestionOutline14 size={14} /></span>
|
|
5687
5687
|
</Tooltip>
|
|
5688
|
-
{restartEnabled && debuggerLatch === null && recovery === null && (
|
|
5688
|
+
{restartEnabled && restartReachable && debuggerLatch === null && recovery === null && (
|
|
5689
5689
|
<Button
|
|
5690
5690
|
variant="primary"
|
|
5691
5691
|
size="sm"
|
package/src/client/locales.ts
CHANGED
|
@@ -129,6 +129,7 @@ export const zh = {
|
|
|
129
129
|
restartHint: '重启方式:关闭当前 dsh 进程后重新运行(例如 dsh web)',
|
|
130
130
|
restartHintSupervised: '本进程是 {0} 服务的主进程,重启交给它负责——市场自己重启会连带杀掉 cgroup 里的接管进程,服务将起不来。请执行 systemctl restart <你的 unit>。确认你的配置能承受市场自行重启(如 KillMode=process),可在本插件配置里手动打开「允许重启」。',
|
|
131
131
|
restartHintDebugged: '当前 dsh 进程正被调试器连接。请从 IDE 或终端停止该进程后重新启动,市场不能从界面里结束正在调试的 dsh。',
|
|
132
|
+
restartHintViaProxy: '你是通过代理或网关访问的,出于安全,重启只接受本机直连。请在运行 dsh 的机器上重启它,例如 docker restart 或 kubectl rollout restart。',
|
|
132
133
|
confirmTitle: '安装',
|
|
133
134
|
confirmWarn: '社区第三方插件,构建脚本默认不运行。',
|
|
134
135
|
installCaution: '安装前请注意',
|
|
@@ -842,6 +843,7 @@ export const en: Record<MarketKey, string> = {
|
|
|
842
843
|
restartHint: 'To restart: stop the current dsh process and run it again (e.g. dsh web)',
|
|
843
844
|
restartHintSupervised: 'This process is {0}\'s own service process, so restarts belong to it — restarting from here would kill the takeover process along with the cgroup and the service would not come back. Use systemctl restart <your unit>. If your unit can survive a self-restart (KillMode=process), turn Allow restart on in this plugin\'s configuration.',
|
|
844
845
|
restartHintDebugged: 'This dsh process is under a debugger. Stop it from your IDE or terminal and start it again — the market cannot stop the debugged process from the UI.',
|
|
846
|
+
restartHintViaProxy: 'You are reaching dsh through a proxy or gateway, and for safety restart only accepts a direct local connection. Restart it on the machine running dsh, for example with docker restart or kubectl rollout restart.',
|
|
845
847
|
confirmTitle: 'Install',
|
|
846
848
|
confirmWarn: 'Community third-party plugin. Build scripts do not run by default.',
|
|
847
849
|
installCaution: 'Before you install',
|
|
@@ -260,6 +260,36 @@ export interface MarketStatus {
|
|
|
260
260
|
* `restart` so `allowRestart` settings are not conflated with debug state.
|
|
261
261
|
*/
|
|
262
262
|
debugger?: string | null
|
|
263
|
+
/**
|
|
264
|
+
* Whether a restart asked for from THIS page could pass the host's fence
|
|
265
|
+
* (#782). False behind a reverse proxy: the restart request is refused there
|
|
266
|
+
* every time, so the banner explains instead of offering a button that cannot
|
|
267
|
+
* work. Absent from an older host, which reads as "reachable" — the old
|
|
268
|
+
* behaviour, never a button taken away on a guess.
|
|
269
|
+
*/
|
|
270
|
+
restartReachable?: boolean
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Which explanation the restart banner carries when it has one (#229, #447,
|
|
275
|
+
* #782). Order matters and is the point: a debugger is the strongest reason (the
|
|
276
|
+
* host will not stop at all), then a proxy (the request itself cannot pass),
|
|
277
|
+
* then a supervisor (restart is someone else's job), else the plain how-to.
|
|
278
|
+
*
|
|
279
|
+
* A pure function so the order is pinned by a test and not by the shape of a JSX
|
|
280
|
+
* ternary — the three earlier states each added a branch to that ternary, and
|
|
281
|
+
* the position of a new one is exactly what is easy to get wrong.
|
|
282
|
+
*/
|
|
283
|
+
export type RestartHintKey = 'restartHintDebugged' | 'restartHintViaProxy' | 'restartHintSupervised' | 'restartHint'
|
|
284
|
+
export function restartHintKey(state: {
|
|
285
|
+
debuggerLatch: string | null
|
|
286
|
+
restartReachable: boolean
|
|
287
|
+
supervisor: string | null
|
|
288
|
+
}): RestartHintKey {
|
|
289
|
+
if (state.debuggerLatch !== null) return 'restartHintDebugged'
|
|
290
|
+
if (!state.restartReachable) return 'restartHintViaProxy'
|
|
291
|
+
if (state.supervisor !== null) return 'restartHintSupervised'
|
|
292
|
+
return 'restartHint'
|
|
263
293
|
}
|
|
264
294
|
|
|
265
295
|
/** Post-install activation state (P0-2), per installed package. */
|
package/src/install.ts
CHANGED
|
@@ -218,6 +218,10 @@ export async function withHoistRecovery(
|
|
|
218
218
|
// SAME argv succeeds — no option is needed, which also means this works
|
|
219
219
|
// on the desktop bridge that refuses market options (#732).
|
|
220
220
|
//
|
|
221
|
+
// Covers both lockfiles pnpm writes through the same no-retry atomic
|
|
222
|
+
// rename: the profile's `pnpm-lock.yaml` and the one inside
|
|
223
|
+
// `node_modules/.pnpm/lock.yaml`.
|
|
224
|
+
//
|
|
221
225
|
// Retried here rather than in the route because `pnpmBlockedByOpenFiles`
|
|
222
226
|
// and the route's rollback both read the FINAL result: leaving this
|
|
223
227
|
// unretried made the route treat a transient lock as "the running host
|
package/src/pnpm-compat.ts
CHANGED
|
@@ -494,29 +494,44 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
|
|
|
494
494
|
: `插件声明的一个依赖版本范围在 registry 上没有可满足的版本${zh},通常是该版本被弃用或从未发布 / a dependency of this plugin declared a version range with no matching release on the registry${en} — the range resolves to nothing (withdrawn or never published)`,
|
|
495
495
|
}
|
|
496
496
|
}
|
|
497
|
-
// #786 follow-up: pnpm could not replace one of the
|
|
498
|
-
// `package.json
|
|
497
|
+
// #786 follow-up: pnpm could not replace one of the profile's own files —
|
|
498
|
+
// `package.json`, `pnpm-lock.yaml` — or the lockfile it keeps INSIDE
|
|
499
|
+
// `node_modules/.pnpm/lock.yaml`. None of these is a package directory.
|
|
499
500
|
//
|
|
500
|
-
// pnpm writes
|
|
501
|
-
// rename and no retry (measured on the bundled 11.7.0: the temp name is
|
|
501
|
+
// pnpm writes all of them through its bundled `write-file-atomic`, which does
|
|
502
|
+
// ONE rename and no retry (measured on the bundled 11.7.0: the temp name is
|
|
502
503
|
// `<file>.<hash>`, e.g. `pnpm-lock.yaml.3015012533`). Any momentary holder —
|
|
503
504
|
// Defender scanning the just-written file, the Windows indexer, an editor —
|
|
504
505
|
// fails the entire run. Measured on the reporter's own profile: 4 identical
|
|
505
506
|
// `add` runs, the 4th failed this way and an immediate 5th succeeded.
|
|
506
507
|
//
|
|
508
|
+
// The `.pnpm/lock.yaml` shape is the SAME failure, not a lesser one: pnpm's
|
|
509
|
+
// `writeLockfiles` writes the wanted lockfile and the current one through the
|
|
510
|
+
// same write-file-atomic inside one `Promise.all` (pnpm 11.7.0, both the
|
|
511
|
+
// equal-lockfile and the differing one branch). So a lock on the temp name
|
|
512
|
+
// `…\.pnpm\lock.yaml.<hash>` fails the run exactly like a lock on
|
|
513
|
+
// `pnpm-lock.yaml` — and answering it as the package-directory case below
|
|
514
|
+
// would roll the profile's lockfile back over a `node_modules` that already
|
|
515
|
+
// holds the new build, which is the desync this whole branch exists to stop.
|
|
516
|
+
//
|
|
507
517
|
// This must be answered BEFORE the package-directory branch below, whose
|
|
508
518
|
// pattern is broad enough to swallow it: that branch blames the running host
|
|
509
519
|
// for holding the plugin's files open and tells the user to quit DSH, which
|
|
510
520
|
// is wrong here (nothing about the plugin is locked) and is the most
|
|
511
521
|
// expensive advice available for a failure that a plain retry clears.
|
|
512
|
-
//
|
|
513
|
-
|
|
522
|
+
//
|
|
523
|
+
// `recoverable: false`, like the other same-argv retries (`transient-network`,
|
|
524
|
+
// `fetch-timeout`): that flag means "re-running `pnpm install` is the
|
|
525
|
+
// documented recovery", and no code reads it to decide a retry — the retry is
|
|
526
|
+
// keyed on this code in `withHoistRecovery`. Marking it true would invite a
|
|
527
|
+
// future reader to rebuild `node_modules` over a one-file rename.
|
|
528
|
+
if (/(?:ERR_PNPM_)?EPERM[^\n]*rename[^\n]*(?:[\\/](?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)\.\d+|[\\/]\.pnpm[\\/]lock\.yaml\.\d+)/i.test(
|
|
514
529
|
withDecodedPnpmDiagnostics(output),
|
|
515
530
|
)) {
|
|
516
531
|
return {
|
|
517
532
|
code: 'profile-file-locked',
|
|
518
|
-
recoverable:
|
|
519
|
-
message: 'pnpm
|
|
533
|
+
recoverable: false,
|
|
534
|
+
message: 'pnpm 写入 package.json 或 pnpm-lock.yaml 时被 Windows 拒绝了一下——通常是杀毒软件或索引服务正好在读这个刚写完的文件。插件本身没有被占用;市场已经自动重试过一次,还是失败,请再试一次 / Windows briefly refused pnpm\'s write of package.json or pnpm-lock.yaml — usually antivirus or the search indexer reading the file pnpm had just written. Nothing about the plugin is locked; the market already retried once and it still failed, so try again',
|
|
520
535
|
}
|
|
521
536
|
}
|
|
522
537
|
// #389 by @qq1054435284: on Windows, pnpm stages the new version in a
|
package/src/restart.ts
CHANGED
|
@@ -194,21 +194,50 @@ export function servingPort(request: Pick<IncomingMessage, 'headers'>): number |
|
|
|
194
194
|
return Number.isInteger(port) && port > 0 && port < 65536 ? port : null
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
-
/**
|
|
198
|
-
|
|
197
|
+
/**
|
|
198
|
+
* The part of the restart fence that does not depend on `Origin`: a loopback
|
|
199
|
+
* peer, no forwarding trace, and a Host that names a loopback authority.
|
|
200
|
+
*
|
|
201
|
+
* One function so the route that ENFORCES the fence and the status poll that
|
|
202
|
+
* PREDICTS it cannot drift apart (#782). It deliberately leaves `Origin` out:
|
|
203
|
+
* a same-origin GET — which is what the status poll is — carries none, so a
|
|
204
|
+
* prediction that required it would call every ordinary local request
|
|
205
|
+
* impossible and take the button away from everyone.
|
|
206
|
+
*/
|
|
207
|
+
function directLoopbackRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
199
208
|
const address = request.socket.remoteAddress
|
|
200
209
|
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
201
210
|
// Any forwarding trace means the loopback peer is a proxy, not the user.
|
|
202
211
|
if (request.headers.forwarded !== undefined
|
|
203
212
|
|| request.headers['x-forwarded-for'] !== undefined
|
|
204
213
|
|| request.headers['x-real-ip'] !== undefined) return false
|
|
205
|
-
const origin = request.headers.origin
|
|
206
|
-
const host = request.headers.host
|
|
207
214
|
// A loopback PEER is not enough: a DNS-rebinding page reaches 127.0.0.1
|
|
208
215
|
// through a name it controls, so the address proves nothing and Host/Origin
|
|
209
216
|
// both carry the attacker's domain (#678). Host is what the attack cannot
|
|
210
217
|
// forge, so it has to name a loopback authority.
|
|
211
|
-
|
|
218
|
+
return loopbackAuthority(request.headers.host)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Whether a restart request made from the same page as THIS request could pass
|
|
223
|
+
* the fence (#782): the answer the status poll reports so the banner can say
|
|
224
|
+
* why there is no button, instead of rendering one that always answers 403.
|
|
225
|
+
*
|
|
226
|
+
* Read off the request that asked for status, not off configuration. Behind an
|
|
227
|
+
* Ingress or any reverse proxy the restart POST arrives from the same page over
|
|
228
|
+
* the same path, so it fails for the same reasons this request shows — a proxy
|
|
229
|
+
* peer, forwarding headers, a non-loopback Host. It is a prediction, never a
|
|
230
|
+
* grant: the route still runs the full fence, including `Origin`, on the POST.
|
|
231
|
+
*/
|
|
232
|
+
export function restartReachableFrom(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
233
|
+
return directLoopbackRequest(request)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Whether a process-control request came from this Web host on loopback. */
|
|
237
|
+
export function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
|
|
238
|
+
if (!directLoopbackRequest(request)) return false
|
|
239
|
+
const origin = request.headers.origin
|
|
240
|
+
const host = request.headers.host
|
|
212
241
|
if (origin === undefined || host === undefined) return false
|
|
213
242
|
try {
|
|
214
243
|
const parsed = new URL(origin)
|
package/src/routes.ts
CHANGED
|
@@ -52,7 +52,7 @@ import { updateNotesFor } from './changelog.ts'
|
|
|
52
52
|
import { checkUpdates, isUpdatablePlugin, compareVersions, fetchNpmLatest, invalidateUpdates, resolveGitRemoteHead, isUpgrade, latestPublishedRecently, setUpdateRegistry, versionOnChannel } from './updates.ts'
|
|
53
53
|
import { createThemeManager, type LoaderEntry } from './themes.ts'
|
|
54
54
|
import { readJsonBody, sameOrigin, sendJson } from './http.ts'
|
|
55
|
-
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig } from './restart.ts'
|
|
55
|
+
import { detectedDebugger, detectedSupervisor, restartAllowed, scheduleRestart, servingPort, trustedRestartRequest, trustedDownloadRequest, type RecoveryHandoffConfig, restartReachableFrom } from './restart.ts'
|
|
56
56
|
import type { RecoveryPlugin } from './recovery.ts'
|
|
57
57
|
import { activationAfterReplace, brokenClientBundles, checkClientBundle, defaultHostRuntimeFacts, hasHostHalf, hostPeerGate, newlyBrokenBundles, peerGateRemedy, verifyActivation } from './verify.ts'
|
|
58
58
|
import {
|
|
@@ -1266,6 +1266,11 @@ export function mountMarketRoutes(
|
|
|
1266
1266
|
// and the page then tells the user to refresh, which is the one thing
|
|
1267
1267
|
// that cannot help.
|
|
1268
1268
|
const native = holdsNativeAddon(config.profile, name, activeProfileDir)
|
|
1269
|
+
// Asked BEFORE the removal, like `native` above and for the same reason:
|
|
1270
|
+
// the row ids come from the package's own bundle patch, which the remove
|
|
1271
|
+
// deletes. Read afterwards this returns [] and the rows survive the
|
|
1272
|
+
// package as boot-time orphans (#799).
|
|
1273
|
+
const ownedRows = rowIdsForPackage(host, activeProfileDir, name)
|
|
1269
1274
|
const result = await runPlugin(config.profile, ['remove', name])
|
|
1270
1275
|
if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
|
|
1271
1276
|
return { ok: false, hot: false, detail: failureDetail(result) }
|
|
@@ -1293,7 +1298,7 @@ export function mountMarketRoutes(
|
|
|
1293
1298
|
if (native) {
|
|
1294
1299
|
logEvent('info', 'uninstall', `${name} ships or depends on a native addon; a restart is needed before it can be installed again`)
|
|
1295
1300
|
}
|
|
1296
|
-
removeRowBlocks(userPatchPath,
|
|
1301
|
+
removeRowBlocks(userPatchPath, ownedRows)
|
|
1297
1302
|
disabled.delete(name)
|
|
1298
1303
|
replacedWhileLive.delete(name)
|
|
1299
1304
|
removeFromGroups({ groups, groupOrder }, name)
|
|
@@ -3361,6 +3366,15 @@ export function mountMarketRoutes(
|
|
|
3361
3366
|
// "no restart button" is the state #229 reported as broken.
|
|
3362
3367
|
supervisor: detectedSupervisor(),
|
|
3363
3368
|
debugger: detectedDebugger(),
|
|
3369
|
+
// Whether a restart asked for from THIS page could pass the route's
|
|
3370
|
+
// fence (#782). Per request, not per config: behind a reverse proxy
|
|
3371
|
+
// the POST arrives over the same path as this GET, so it fails for
|
|
3372
|
+
// the same reasons — and the banner used to render a button that
|
|
3373
|
+
// answered 403 every time, with nothing saying so in advance. It is
|
|
3374
|
+
// a prediction: the route still runs the whole fence on the POST.
|
|
3375
|
+
// Deliberately NOT folded into `restart`, which is also what the
|
|
3376
|
+
// settings page reads and writes back as the user's own choice.
|
|
3377
|
+
restartReachable: restartReachableFrom(request),
|
|
3364
3378
|
selfManaged: installed.dshmarket !== undefined || installed['dsh-market'] !== undefined,
|
|
3365
3379
|
// Whether the host took the market's settings namespace (#677).
|
|
3366
3380
|
// `unsupported-by-host` is 0.1.7 and newer, where settings come from
|
|
@@ -4234,6 +4248,21 @@ sendJson(response, 200, { updates })
|
|
|
4234
4248
|
// declaration is unchanged; a failed run restores the manifest from
|
|
4235
4249
|
// the snapshot above.
|
|
4236
4250
|
//
|
|
4251
|
+
// `reresolveInPlace` is not the only shape whose diff is empty.
|
|
4252
|
+
// When the manifest holds the spelling pnpm itself wrote for a
|
|
4253
|
+
// GitHub source — `git+https://owner/repo.git` — and the market
|
|
4254
|
+
// sends the `github:owner/repo` shortcut, the target differs from
|
|
4255
|
+
// the specifier but pnpm still gives the specifier back unchanged,
|
|
4256
|
+
// so the host answers `ambiguous-install` again (#803; measured on
|
|
4257
|
+
// pnpm 11.7.0, the version DSH Desktop bundles, against the
|
|
4258
|
+
// reporter's own spec pair). One question covers both: pnpm leaves
|
|
4259
|
+
// the specifier as it was, so the diff the host reads is empty.
|
|
4260
|
+
//
|
|
4261
|
+
// The pin is the market's own write to a foreign pipeline, so it is
|
|
4262
|
+
// scoped to the host that reads the manifest itself. A host that
|
|
4263
|
+
// takes market options lets the market verify its own run, and gets
|
|
4264
|
+
// no extra manifest write.
|
|
4265
|
+
//
|
|
4237
4266
|
// A `#path:` spec needs the local pin instead of the host's exact
|
|
4238
4267
|
// rollback target: `gitTargetAtCommit` refuses the `&` that carries
|
|
4239
4268
|
// a selector beside a commit, because dsh-cli's target grammar has
|
|
@@ -4243,7 +4272,9 @@ sendJson(response, 200, { updates })
|
|
|
4243
4272
|
const pinValue = gitRollbackTarget ?? (hasGitSubpath && beforeCommit !== null
|
|
4244
4273
|
? pinSpecToCommit(spec, beforeCommit)
|
|
4245
4274
|
: null)
|
|
4246
|
-
const
|
|
4275
|
+
const identifiesInstalledByManifestDiff = !marketFlags
|
|
4276
|
+
const pinnedForIdentification = isGit && !restore
|
|
4277
|
+
&& identifiesInstalledByManifestDiff
|
|
4247
4278
|
&& pinValue !== null && pinValue !== spec
|
|
4248
4279
|
if (pinnedForIdentification) {
|
|
4249
4280
|
declareProfileDependency(config.profile, name, pinValue, activeProfileDir)
|
|
@@ -5402,6 +5433,12 @@ sendJson(response, 200, { updates })
|
|
|
5402
5433
|
// `hot` would send the user to a page refresh, which is the one
|
|
5403
5434
|
// thing that cannot help.
|
|
5404
5435
|
const heldNativeAddon = holdsNativeAddon(config.profile, name, activeProfileDir)
|
|
5436
|
+
// And once more, for the same reason: `rowIdsForPackage` names the
|
|
5437
|
+
// rows from the package's own bundle patch, which the remove below
|
|
5438
|
+
// deletes. Asked afterwards it answers [] on exactly the uninstall
|
|
5439
|
+
// it is meant to clean up after, and the rows outlive the package
|
|
5440
|
+
// as boot-time orphans (#799).
|
|
5441
|
+
const ownedRows = rowIdsForPackage(host, activeProfileDir, name)
|
|
5405
5442
|
const result = await runPlugin(config.profile, ['remove', name])
|
|
5406
5443
|
const cancelled = result.cancelled
|
|
5407
5444
|
const ok = result.exitCode === 0 && !result.timedOut && !cancelled
|
|
@@ -5465,7 +5502,7 @@ sendJson(response, 200, { updates })
|
|
|
5465
5502
|
// Patch-layer rows must not survive the remove either: a
|
|
5466
5503
|
// `- id: X` + `disabled: true` row for a package that no longer
|
|
5467
5504
|
// mounts is a boot-time orphan (port of dsh-plugin-hub).
|
|
5468
|
-
removeRowBlocks(userPatchPath,
|
|
5505
|
+
removeRowBlocks(userPatchPath, ownedRows)
|
|
5469
5506
|
// The disable list must not keep a removed plugin: a later
|
|
5470
5507
|
// reinstall starts enabled. Group memberships follow the same
|
|
5471
5508
|
// rule so no group toggle ever targets a ghost member.
|