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 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 &lt;your unit&gt;. 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: debuggerLatch !== null ? t("restartHintDebugged") : supervisor === null ? t("restartHint") : t("restartHintSupervised").replace("{0}", supervisor),
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
- for (const entry of readdirSync(join(dshInstallDir, 'node_modules', '@deepseek-ai'), { withFileTypes: true })) {
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
- const host = dshInstall !== null ? readNodeModulesVersion(dshInstall, name) : null;
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
@@ -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 PROFILE's own files —
460
- // `package.json` or `pnpm-lock.yaml` — not a package directory.
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 both through its bundled `write-file-atomic`, which does ONE
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
- // `recoverable: true` is what lets withHoistRecovery retry it automatically.
475
- if (/(?:ERR_PNPM_)?EPERM[^\n]*rename[^\n]*[\\/](?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)\.\d+/i.test(withDecodedPnpmDiagnostics(output))) {
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: true,
479
- message: 'pnpm 替换 profile 自己的文件(package.json / pnpm-lock.yaml)时被 Windows 拒绝了一下——通常是杀毒软件或索引服务正好在读这个刚写完的文件。插件本身没有被占用,重试即可;市场会自动重试一次 / Windows briefly refused pnpm\'s replacement of one of the profile\'s own files (package.json / pnpm-lock.yaml) — usually antivirus or the search indexer reading the file pnpm had just written. Nothing about the plugin is locked; a retry clears it, and the market retries once automatically',
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
- /** Whether a process-control request came from this Web host on loopback. */
178
- export function trustedRestartRequest(request) {
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
- if (!loopbackAuthority(host))
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, rowIdsForPackage(host, activeProfileDir, name));
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 pinnedForIdentification = reresolveInPlace && !inPlaceUpdate
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, rowIdsForPackage(host, activeProfileDir, name));
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.
@@ -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.9",
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
- for (const entry of readdirSync(join(dshInstallDir, 'node_modules', '@deepseek-ai'), { withFileTypes: true })) {
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
- const host = dshInstall !== null ? readNodeModulesVersion(dshInstall, name) : null
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"
@@ -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 &lt;your unit&gt;. 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
@@ -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 PROFILE's own files —
498
- // `package.json` or `pnpm-lock.yaml` — not a package directory.
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 both through its bundled `write-file-atomic`, which does ONE
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
- // `recoverable: true` is what lets withHoistRecovery retry it automatically.
513
- if (/(?:ERR_PNPM_)?EPERM[^\n]*rename[^\n]*[\\/](?:package\.json|pnpm-lock\.yaml|pnpm-workspace\.yaml)\.\d+/i.test(
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: true,
519
- message: 'pnpm 替换 profile 自己的文件(package.json / pnpm-lock.yaml)时被 Windows 拒绝了一下——通常是杀毒软件或索引服务正好在读这个刚写完的文件。插件本身没有被占用,重试即可;市场会自动重试一次 / Windows briefly refused pnpm\'s replacement of one of the profile\'s own files (package.json / pnpm-lock.yaml) — usually antivirus or the search indexer reading the file pnpm had just written. Nothing about the plugin is locked; a retry clears it, and the market retries once automatically',
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
- /** Whether a process-control request came from this Web host on loopback. */
198
- export function trustedRestartRequest(request: Pick<IncomingMessage, 'headers' | 'socket'>): boolean {
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
- if (!loopbackAuthority(host)) return false
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, rowIdsForPackage(host, activeProfileDir, name))
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 pinnedForIdentification = reresolveInPlace && !inPlaceUpdate
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, rowIdsForPackage(host, activeProfileDir, name))
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.