dshmarket 1.66.9 → 1.66.11

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))
@@ -910,47 +924,80 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
910
924
  * @param dshInstall - install directory the analysis located, or null.
911
925
  * @returns the overlay layer, or null when this installation declares none.
912
926
  */
913
- function installOverlayLayer(dshInstall) {
927
+ function installOverlayLayer(dshInstall, profileDirectory, declaredBundles) {
914
928
  for (const directory of desktopApplicationRoots(dshInstall)) {
915
- let manifest;
916
- try {
917
- manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
918
- }
919
- catch {
920
- continue;
921
- }
922
- const declared = manifest.dsh?.bundle?.patch;
923
- const declaredList = typeof declared === 'string'
924
- ? [declared]
925
- : Array.isArray(declared)
926
- ? declared.filter((relative) => typeof relative === 'string')
927
- : [];
928
- if (declaredList.length === 0)
929
- continue;
930
- // A bundle patch alone does not make a directory an installation: ANY
931
- // package can declare one — this repository does, and so does every plugin
932
- // repo. Without this bound the ancestor walk accepted the nearest such
933
- // project above the install directory and composed its rows as if they
934
- // were the installation's own, which invents rows and can mask a real
935
- // orphan warning (#749 review). A DSH installation ships the in-box web
936
- // bundle, and the launcher splices this very overlay directly behind that
937
- // bundle's layer — where it is absent, the launcher applies no overlay
938
- // either, so composing one here would describe a composition nobody runs.
939
- if (readNodeModulesVersion(directory, '@deepseek-ai/dsh-web-app') === null)
940
- continue;
941
- const label = typeof manifest.name === 'string' && manifest.name !== '' ? manifest.name : 'install-overlay';
942
- const paths = declaredList.map(relative => join(directory, relative));
943
- if (paths.some(path => !existsSync(path))) {
944
- return { label, kind: 'bundle', patches: [], parseError: 'declared patch is missing' };
945
- }
946
- const parsed = paths.map(path => parsePatchFile(path));
947
- if (parsed.some(patches => patches === null)) {
948
- return { label, kind: 'bundle', patches: [], parseError: 'patch file is not a valid entry list' };
929
+ // A DSH installation ships the in-box web bundle, and the launcher splices
930
+ // this very overlay directly behind that bundle's layer — where it is
931
+ // absent, the launcher applies no overlay either, so composing one here
932
+ // would describe a composition nobody runs (#749 review).
933
+ const layer = overlayFromDirectory(directory, manifest => readNodeModulesVersion(directory, '@deepseek-ai/dsh-web-app') !== null);
934
+ if (layer !== null)
935
+ return layer;
936
+ }
937
+ // The official Desktop shell plugin's own patch (#807). On a build that packs
938
+ // the whole runtime into one app.asar, no application-root candidate declares
939
+ // it: the asar's root is `@deepseek-ai/dsh-desktop` and the nested
940
+ // `app.asar/dsh` is `@deepseek-ai/dsh-desktop-runtime`, neither with a
941
+ // `dsh.bundle.patch`, while the file that really supplies the shell's rows
942
+ // (`desktop-shell`, `desktop-notifications`, …) is this package's own
943
+ // `cordis.patch.yml`. Without it every user-patch row aimed at a shell row is
944
+ // reported as an orphan, and a user who believes the warning deletes rows
945
+ // that carry the window material and the notification preferences.
946
+ //
947
+ // Bounded by identity, not by location: the candidate must BE that package,
948
+ // resolved from the profile's own module search, and must declare a patch.
949
+ // A profile that lists it in `dsh.profile.bundles` gets it as an ordinary
950
+ // bundle layer instead — composing both would add every row twice.
951
+ if (!declaredBundles.includes(DESKTOP_SHELL_PLUGIN)) {
952
+ const directory = resolvePackageDir(join(profileDirectory, 'package.json'), DESKTOP_SHELL_PLUGIN);
953
+ if (directory !== null) {
954
+ return overlayFromDirectory(directory, manifest => manifest.name === DESKTOP_SHELL_PLUGIN);
949
955
  }
950
- return { label, kind: 'bundle', patches: parsed.flatMap(patches => patches), parseError: null };
951
956
  }
952
957
  return null;
953
958
  }
959
+ /** The official Desktop shell plugin, whose patch the launcher applies by hand (#807). */
960
+ const DESKTOP_SHELL_PLUGIN = 'dsh-plugin-desktop';
961
+ /**
962
+ * The overlay one candidate directory declares, or null when it declares none.
963
+ *
964
+ * `accept` is the caller's bound on what may stand in for the installation;
965
+ * resolution and parsing are shared, because a second parsing path would be a
966
+ * second set of answers about the same file.
967
+ */
968
+ function overlayFromDirectory(directory, accept) {
969
+ let manifest;
970
+ try {
971
+ manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8'));
972
+ }
973
+ catch {
974
+ return null;
975
+ }
976
+ const declared = manifest.dsh?.bundle?.patch;
977
+ const declaredList = typeof declared === 'string'
978
+ ? [declared]
979
+ : Array.isArray(declared)
980
+ ? declared.filter((relative) => typeof relative === 'string')
981
+ : [];
982
+ // A bundle patch alone does not make a directory an installation: ANY package
983
+ // can declare one — this repository does, and so does every plugin repo.
984
+ // Without the caller's bound the ancestor walk accepted the nearest such
985
+ // project above the install directory and composed its rows as if they were
986
+ // the installation's own, which invents rows and can mask a real orphan
987
+ // warning (#749 review).
988
+ if (declaredList.length === 0 || !accept(manifest))
989
+ return null;
990
+ const label = typeof manifest.name === 'string' && manifest.name !== '' ? manifest.name : 'install-overlay';
991
+ const paths = declaredList.map(relative => join(directory, relative));
992
+ if (paths.some(path => !existsSync(path))) {
993
+ return { label, kind: 'bundle', patches: [], parseError: 'declared patch is missing' };
994
+ }
995
+ const parsed = paths.map(path => parsePatchFile(path));
996
+ if (parsed.some(patches => patches === null)) {
997
+ return { label, kind: 'bundle', patches: [], parseError: 'patch file is not a valid entry list' };
998
+ }
999
+ return { label, kind: 'bundle', patches: parsed.flatMap(patches => patches), parseError: null };
1000
+ }
954
1001
  /**
955
1002
  * Which directories in `node_modules` are leftovers (#663).
956
1003
  *
@@ -1066,7 +1113,7 @@ export function analyzeProfile(profileDirectory, options = {}) {
1066
1113
  // The installation's own overlay rides directly behind the bundle the
1067
1114
  // Desktop launcher keys off, exactly where the launcher splices it — see
1068
1115
  // installOverlayLayer.
1069
- const installOverlay = installOverlayLayer(dshInstall);
1116
+ const installOverlay = installOverlayLayer(dshInstall, profileDirectory, bundleNames);
1070
1117
  if (installOverlay !== null) {
1071
1118
  const overlayAfter = bundleLayers.findIndex(layer => layer.label === '@deepseek-ai/dsh-web-app');
1072
1119
  if (overlayAfter < 0)
@@ -1128,7 +1175,22 @@ export function analyzeProfile(profileDirectory, options = {}) {
1128
1175
  ? readNodeModulesVersion(profileDirectory, name)
1129
1176
  : readProfileVisibleVersion(profileDirectory, name);
1130
1177
  const nested = readNodeModulesVersion(pluginDir, name);
1131
- const host = dshInstall !== null ? readNodeModulesVersion(dshInstall, name) : null;
1178
+ // The located installation is asked as `host` here — and `dshInstall` is
1179
+ // the PACKAGE directory `dshHostInfo()` answers (the CLI layout
1180
+ // `<prefix>/node_modules/@deepseek-ai/dsh`, and the #778 nested form
1181
+ // `<resources>/app.asar/dsh/node_modules/@deepseek-ai/dsh`), or a flat
1182
+ // Desktop deployment root. `readNodeModulesVersion` takes a package root
1183
+ // and appends `node_modules` itself, so passing a package directory read
1184
+ // `<package>/node_modules/<name>`: absent in most layouts, which silently
1185
+ // left the located installation unasked while the comment above promises
1186
+ // it is asked; and where the host package carries its own nested
1187
+ // `@deepseek-ai` scope (pnpm isolated trees) it answered from that
1188
+ // truncated subset instead — the opposite of the "unknown rather than
1189
+ // wrong" this rule promises. `hostNodeModulesRoot()` derives the shared
1190
+ // root from both shapes (#804's fix, same arithmetic).
1191
+ const host = dshInstall !== null
1192
+ ? readNodeModulesVersion(dirname(hostNodeModulesRoot(dshInstall)), name)
1193
+ : null;
1132
1194
  // Node resolves a plugin's peer from its OWN node_modules first
1133
1195
  // (nested), then the profile tree (hoisted), then the host install.
1134
1196
  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';
@@ -224,6 +224,28 @@ function blockedBuilds(result) {
224
224
  printedBuildKeys.set(pending, parsePrepareKey(result.stdout, result.stderr));
225
225
  return [pending];
226
226
  }
227
+ /**
228
+ * Whether a FAILED run failed BECAUSE pnpm skipped a build script (#754).
229
+ *
230
+ * `blockedBuilds` answers a different question — "did pnpm skip anything" —
231
+ * and pnpm reports skipped builds for transitive dependencies on almost any
232
+ * run. So a failure with an unrelated cause (a peer-resolution refusal, a 404,
233
+ * an OOM) still came back carrying the approve-and-retry banner, and pressing
234
+ * it re-ran the same command to the same failure. The reporter's case is the
235
+ * sharp end: the skipped package was a transitive `@types/*` that is not in
236
+ * the profile and not in the catalog, so approval had nothing to approve and
237
+ * the button answered `no installed packages given` — while the real cause
238
+ * (`generation peer validation failed`) was buried under it.
239
+ *
240
+ * The classifier already decides this from pnpm's own output, so ask it rather
241
+ * than pattern-matching a second time. `git-prepare-not-allowed` is the same
242
+ * refusal for a git dependency, which is the other code the approval path can
243
+ * clear.
244
+ */
245
+ function failedOnBlockedBuilds(result) {
246
+ const code = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code;
247
+ return code === 'ignored-builds' || code === 'git-prepare-not-allowed';
248
+ }
227
249
  /**
228
250
  * Register the market's HTTP routes.
229
251
  * @param host - Acquired webServer + shell services.
@@ -1114,6 +1136,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1114
1136
  // and the page then tells the user to refresh, which is the one thing
1115
1137
  // that cannot help.
1116
1138
  const native = holdsNativeAddon(config.profile, name, activeProfileDir);
1139
+ // Asked BEFORE the removal, like `native` above and for the same reason:
1140
+ // the row ids come from the package's own bundle patch, which the remove
1141
+ // deletes. Read afterwards this returns [] and the rows survive the
1142
+ // package as boot-time orphans (#799).
1143
+ const ownedRows = rowIdsForPackage(host, activeProfileDir, name);
1117
1144
  const result = await runPlugin(config.profile, ['remove', name]);
1118
1145
  if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
1119
1146
  return { ok: false, hot: false, detail: failureDetail(result) };
@@ -1141,7 +1168,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
1141
1168
  if (native) {
1142
1169
  logEvent('info', 'uninstall', `${name} ships or depends on a native addon; a restart is needed before it can be installed again`);
1143
1170
  }
1144
- removeRowBlocks(userPatchPath, rowIdsForPackage(host, activeProfileDir, name));
1171
+ removeRowBlocks(userPatchPath, ownedRows);
1145
1172
  disabled.delete(name);
1146
1173
  replacedWhileLive.delete(name);
1147
1174
  removeFromGroups({ groups, groupOrder }, name);
@@ -3199,6 +3226,15 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
3199
3226
  // "no restart button" is the state #229 reported as broken.
3200
3227
  supervisor: detectedSupervisor(),
3201
3228
  debugger: detectedDebugger(),
3229
+ // Whether a restart asked for from THIS page could pass the route's
3230
+ // fence (#782). Per request, not per config: behind a reverse proxy
3231
+ // the POST arrives over the same path as this GET, so it fails for
3232
+ // the same reasons — and the banner used to render a button that
3233
+ // answered 403 every time, with nothing saying so in advance. It is
3234
+ // a prediction: the route still runs the whole fence on the POST.
3235
+ // Deliberately NOT folded into `restart`, which is also what the
3236
+ // settings page reads and writes back as the user's own choice.
3237
+ restartReachable: restartReachableFrom(request),
3202
3238
  selfManaged: installed.dshmarket !== undefined || installed['dsh-market'] !== undefined,
3203
3239
  // Whether the host took the market's settings namespace (#677).
3204
3240
  // `unsupported-by-host` is 0.1.7 and newer, where settings come from
@@ -4067,6 +4103,21 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4067
4103
  // declaration is unchanged; a failed run restores the manifest from
4068
4104
  // the snapshot above.
4069
4105
  //
4106
+ // `reresolveInPlace` is not the only shape whose diff is empty.
4107
+ // When the manifest holds the spelling pnpm itself wrote for a
4108
+ // GitHub source — `git+https://owner/repo.git` — and the market
4109
+ // sends the `github:owner/repo` shortcut, the target differs from
4110
+ // the specifier but pnpm still gives the specifier back unchanged,
4111
+ // so the host answers `ambiguous-install` again (#803; measured on
4112
+ // pnpm 11.7.0, the version DSH Desktop bundles, against the
4113
+ // reporter's own spec pair). One question covers both: pnpm leaves
4114
+ // the specifier as it was, so the diff the host reads is empty.
4115
+ //
4116
+ // The pin is the market's own write to a foreign pipeline, so it is
4117
+ // scoped to the host that reads the manifest itself. A host that
4118
+ // takes market options lets the market verify its own run, and gets
4119
+ // no extra manifest write.
4120
+ //
4070
4121
  // A `#path:` spec needs the local pin instead of the host's exact
4071
4122
  // rollback target: `gitTargetAtCommit` refuses the `&` that carries
4072
4123
  // a selector beside a commit, because dsh-cli's target grammar has
@@ -4076,7 +4127,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4076
4127
  const pinValue = gitRollbackTarget ?? (hasGitSubpath && beforeCommit !== null
4077
4128
  ? pinSpecToCommit(spec, beforeCommit)
4078
4129
  : null);
4079
- const pinnedForIdentification = reresolveInPlace && !inPlaceUpdate
4130
+ const identifiesInstalledByManifestDiff = !marketFlags;
4131
+ const pinnedForIdentification = isGit && !restore
4132
+ && identifiesInstalledByManifestDiff
4080
4133
  && pinValue !== null && pinValue !== spec;
4081
4134
  if (pinnedForIdentification) {
4082
4135
  declareProfileDependency(config.profile, name, pinValue, activeProfileDir);
@@ -4495,7 +4548,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
4495
4548
  // build-required dep fails the add with ERR_PNPM_IGNORED_BUILDS.
4496
4549
  // Reporting the blocked packages here gives the client the same
4497
4550
  // approve-and-retry banner the install flow has had since #6.
4498
- const ignoredBuilds = ok || cancelled ? undefined : blockedBuilds(result);
4551
+ // Same rule as the install route (#754): a failure that merely
4552
+ // MENTIONS a skipped build is not a failure ABOUT one, and offering
4553
+ // to approve it sends the user to press a button that cannot help.
4554
+ const ignoredBuilds = ok || cancelled || !failedOnBlockedBuilds(result) ? undefined : blockedBuilds(result);
4499
4555
  if (ok)
4500
4556
  clearBrokenPlugin(name);
4501
4557
  // A package still installed but no longer in dsh.profile.bundles
@@ -5214,6 +5270,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5214
5270
  // `hot` would send the user to a page refresh, which is the one
5215
5271
  // thing that cannot help.
5216
5272
  const heldNativeAddon = holdsNativeAddon(config.profile, name, activeProfileDir);
5273
+ // And once more, for the same reason: `rowIdsForPackage` names the
5274
+ // rows from the package's own bundle patch, which the remove below
5275
+ // deletes. Asked afterwards it answers [] on exactly the uninstall
5276
+ // it is meant to clean up after, and the rows outlive the package
5277
+ // as boot-time orphans (#799).
5278
+ const ownedRows = rowIdsForPackage(host, activeProfileDir, name);
5217
5279
  const result = await runPlugin(config.profile, ['remove', name]);
5218
5280
  const cancelled = result.cancelled;
5219
5281
  const ok = result.exitCode === 0 && !result.timedOut && !cancelled;
@@ -5277,7 +5339,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5277
5339
  // Patch-layer rows must not survive the remove either: a
5278
5340
  // `- id: X` + `disabled: true` row for a package that no longer
5279
5341
  // mounts is a boot-time orphan (port of dsh-plugin-hub).
5280
- removeRowBlocks(userPatchPath, rowIdsForPackage(host, activeProfileDir, name));
5342
+ removeRowBlocks(userPatchPath, ownedRows);
5281
5343
  // The disable list must not keep a removed plugin: a later
5282
5344
  // reinstall starts enabled. Group memberships follow the same
5283
5345
  // rule so no group toggle ever targets a ghost member.
@@ -5847,7 +5909,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup, ho
5847
5909
  if (ok)
5848
5910
  clearBrokenPlugin(entry.name);
5849
5911
  logEvent(ok || cancelled ? 'info' : 'error', 'install', `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` err=${failureDetail(result)}`}`);
5850
- const ignoredBuilds = blockedBuilds(result);
5912
+ // On success a skipped build is a notice (#6); after a FAILURE the
5913
+ // list is what offers the approve-and-retry banner, so it is sent
5914
+ // only when the blocked build IS why the run failed (#754).
5915
+ const ignoredBuilds = ok || cancelled || failedOnBlockedBuilds(result) ? blockedBuilds(result) : undefined;
5851
5916
  sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
5852
5917
  ok,
5853
5918
  cancelled: cancelled || undefined,
@@ -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.11",
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
  }
@@ -1131,45 +1145,88 @@ export function buildBundleLayers(
1131
1145
  * @param dshInstall - install directory the analysis located, or null.
1132
1146
  * @returns the overlay layer, or null when this installation declares none.
1133
1147
  */
1134
- function installOverlayLayer(dshInstall: string | null): LayerInput | null {
1148
+ function installOverlayLayer(
1149
+ dshInstall: string | null,
1150
+ profileDirectory: string,
1151
+ declaredBundles: readonly string[],
1152
+ ): LayerInput | null {
1135
1153
  for (const directory of desktopApplicationRoots(dshInstall)) {
1136
- let manifest: { name?: unknown; dsh?: { bundle?: { patch?: unknown } } }
1137
- try {
1138
- manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as typeof manifest
1139
- } catch {
1140
- continue
1141
- }
1142
- const declared = manifest.dsh?.bundle?.patch
1143
- const declaredList = typeof declared === 'string'
1144
- ? [declared]
1145
- : Array.isArray(declared)
1146
- ? declared.filter((relative): relative is string => typeof relative === 'string')
1147
- : []
1148
- if (declaredList.length === 0) continue
1149
- // A bundle patch alone does not make a directory an installation: ANY
1150
- // package can declare one — this repository does, and so does every plugin
1151
- // repo. Without this bound the ancestor walk accepted the nearest such
1152
- // project above the install directory and composed its rows as if they
1153
- // were the installation's own, which invents rows and can mask a real
1154
- // orphan warning (#749 review). A DSH installation ships the in-box web
1155
- // bundle, and the launcher splices this very overlay directly behind that
1156
- // bundle's layer — where it is absent, the launcher applies no overlay
1157
- // either, so composing one here would describe a composition nobody runs.
1158
- if (readNodeModulesVersion(directory, '@deepseek-ai/dsh-web-app') === null) continue
1159
- const label = typeof manifest.name === 'string' && manifest.name !== '' ? manifest.name : 'install-overlay'
1160
- const paths = declaredList.map(relative => join(directory, relative))
1161
- if (paths.some(path => !existsSync(path))) {
1162
- return { label, kind: 'bundle', patches: [], parseError: 'declared patch is missing' }
1163
- }
1164
- const parsed = paths.map(path => parsePatchFile(path))
1165
- if (parsed.some(patches => patches === null)) {
1166
- return { label, kind: 'bundle', patches: [], parseError: 'patch file is not a valid entry list' }
1154
+ // A DSH installation ships the in-box web bundle, and the launcher splices
1155
+ // this very overlay directly behind that bundle's layer — where it is
1156
+ // absent, the launcher applies no overlay either, so composing one here
1157
+ // would describe a composition nobody runs (#749 review).
1158
+ const layer = overlayFromDirectory(directory, manifest =>
1159
+ readNodeModulesVersion(directory, '@deepseek-ai/dsh-web-app') !== null)
1160
+ if (layer !== null) return layer
1161
+ }
1162
+ // The official Desktop shell plugin's own patch (#807). On a build that packs
1163
+ // the whole runtime into one app.asar, no application-root candidate declares
1164
+ // it: the asar's root is `@deepseek-ai/dsh-desktop` and the nested
1165
+ // `app.asar/dsh` is `@deepseek-ai/dsh-desktop-runtime`, neither with a
1166
+ // `dsh.bundle.patch`, while the file that really supplies the shell's rows
1167
+ // (`desktop-shell`, `desktop-notifications`, …) is this package's own
1168
+ // `cordis.patch.yml`. Without it every user-patch row aimed at a shell row is
1169
+ // reported as an orphan, and a user who believes the warning deletes rows
1170
+ // that carry the window material and the notification preferences.
1171
+ //
1172
+ // Bounded by identity, not by location: the candidate must BE that package,
1173
+ // resolved from the profile's own module search, and must declare a patch.
1174
+ // A profile that lists it in `dsh.profile.bundles` gets it as an ordinary
1175
+ // bundle layer instead — composing both would add every row twice.
1176
+ if (!declaredBundles.includes(DESKTOP_SHELL_PLUGIN)) {
1177
+ const directory = resolvePackageDir(join(profileDirectory, 'package.json'), DESKTOP_SHELL_PLUGIN)
1178
+ if (directory !== null) {
1179
+ return overlayFromDirectory(directory, manifest => manifest.name === DESKTOP_SHELL_PLUGIN)
1167
1180
  }
1168
- return { label, kind: 'bundle', patches: parsed.flatMap(patches => patches), parseError: null }
1169
1181
  }
1170
1182
  return null
1171
1183
  }
1172
1184
 
1185
+ /** The official Desktop shell plugin, whose patch the launcher applies by hand (#807). */
1186
+ const DESKTOP_SHELL_PLUGIN = 'dsh-plugin-desktop'
1187
+
1188
+ /**
1189
+ * The overlay one candidate directory declares, or null when it declares none.
1190
+ *
1191
+ * `accept` is the caller's bound on what may stand in for the installation;
1192
+ * resolution and parsing are shared, because a second parsing path would be a
1193
+ * second set of answers about the same file.
1194
+ */
1195
+ function overlayFromDirectory(
1196
+ directory: string,
1197
+ accept: (manifest: { name?: unknown }) => boolean,
1198
+ ): LayerInput | null {
1199
+ let manifest: { name?: unknown; dsh?: { bundle?: { patch?: unknown } } }
1200
+ try {
1201
+ manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as typeof manifest
1202
+ } catch {
1203
+ return null
1204
+ }
1205
+ const declared = manifest.dsh?.bundle?.patch
1206
+ const declaredList = typeof declared === 'string'
1207
+ ? [declared]
1208
+ : Array.isArray(declared)
1209
+ ? declared.filter((relative): relative is string => typeof relative === 'string')
1210
+ : []
1211
+ // A bundle patch alone does not make a directory an installation: ANY package
1212
+ // can declare one — this repository does, and so does every plugin repo.
1213
+ // Without the caller's bound the ancestor walk accepted the nearest such
1214
+ // project above the install directory and composed its rows as if they were
1215
+ // the installation's own, which invents rows and can mask a real orphan
1216
+ // warning (#749 review).
1217
+ if (declaredList.length === 0 || !accept(manifest)) return null
1218
+ const label = typeof manifest.name === 'string' && manifest.name !== '' ? manifest.name : 'install-overlay'
1219
+ const paths = declaredList.map(relative => join(directory, relative))
1220
+ if (paths.some(path => !existsSync(path))) {
1221
+ return { label, kind: 'bundle', patches: [], parseError: 'declared patch is missing' }
1222
+ }
1223
+ const parsed = paths.map(path => parsePatchFile(path))
1224
+ if (parsed.some(patches => patches === null)) {
1225
+ return { label, kind: 'bundle', patches: [], parseError: 'patch file is not a valid entry list' }
1226
+ }
1227
+ return { label, kind: 'bundle', patches: parsed.flatMap(patches => patches), parseError: null }
1228
+ }
1229
+
1173
1230
  /**
1174
1231
  * Which directories in `node_modules` are leftovers (#663).
1175
1232
  *
@@ -1285,7 +1342,7 @@ export function analyzeProfile(profileDirectory: string, options: CheckOptions =
1285
1342
  // The installation's own overlay rides directly behind the bundle the
1286
1343
  // Desktop launcher keys off, exactly where the launcher splices it — see
1287
1344
  // installOverlayLayer.
1288
- const installOverlay = installOverlayLayer(dshInstall)
1345
+ const installOverlay = installOverlayLayer(dshInstall, profileDirectory, bundleNames)
1289
1346
  if (installOverlay !== null) {
1290
1347
  const overlayAfter = bundleLayers.findIndex(layer => layer.label === '@deepseek-ai/dsh-web-app')
1291
1348
  if (overlayAfter < 0) bundleLayers.push(installOverlay)
@@ -1346,7 +1403,22 @@ export function analyzeProfile(profileDirectory: string, options: CheckOptions =
1346
1403
  ? readNodeModulesVersion(profileDirectory, name)
1347
1404
  : readProfileVisibleVersion(profileDirectory, name)
1348
1405
  const nested = readNodeModulesVersion(pluginDir, name)
1349
- const host = dshInstall !== null ? readNodeModulesVersion(dshInstall, name) : null
1406
+ // The located installation is asked as `host` here — and `dshInstall` is
1407
+ // the PACKAGE directory `dshHostInfo()` answers (the CLI layout
1408
+ // `<prefix>/node_modules/@deepseek-ai/dsh`, and the #778 nested form
1409
+ // `<resources>/app.asar/dsh/node_modules/@deepseek-ai/dsh`), or a flat
1410
+ // Desktop deployment root. `readNodeModulesVersion` takes a package root
1411
+ // and appends `node_modules` itself, so passing a package directory read
1412
+ // `<package>/node_modules/<name>`: absent in most layouts, which silently
1413
+ // left the located installation unasked while the comment above promises
1414
+ // it is asked; and where the host package carries its own nested
1415
+ // `@deepseek-ai` scope (pnpm isolated trees) it answered from that
1416
+ // truncated subset instead — the opposite of the "unknown rather than
1417
+ // wrong" this rule promises. `hostNodeModulesRoot()` derives the shared
1418
+ // root from both shapes (#804's fix, same arithmetic).
1419
+ const host = dshInstall !== null
1420
+ ? readNodeModulesVersion(dirname(hostNodeModulesRoot(dshInstall)), name)
1421
+ : null
1350
1422
  // Node resolves a plugin's peer from its OWN node_modules first
1351
1423
  // (nested), then the profile tree (hoisted), then the host install.
1352
1424
  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 {
@@ -315,6 +315,29 @@ function blockedBuilds(result: { ignoredBuilds?: unknown; stdout: string; stderr
315
315
  return [pending]
316
316
  }
317
317
 
318
+ /**
319
+ * Whether a FAILED run failed BECAUSE pnpm skipped a build script (#754).
320
+ *
321
+ * `blockedBuilds` answers a different question — "did pnpm skip anything" —
322
+ * and pnpm reports skipped builds for transitive dependencies on almost any
323
+ * run. So a failure with an unrelated cause (a peer-resolution refusal, a 404,
324
+ * an OOM) still came back carrying the approve-and-retry banner, and pressing
325
+ * it re-ran the same command to the same failure. The reporter's case is the
326
+ * sharp end: the skipped package was a transitive `@types/*` that is not in
327
+ * the profile and not in the catalog, so approval had nothing to approve and
328
+ * the button answered `no installed packages given` — while the real cause
329
+ * (`generation peer validation failed`) was buried under it.
330
+ *
331
+ * The classifier already decides this from pnpm's own output, so ask it rather
332
+ * than pattern-matching a second time. `git-prepare-not-allowed` is the same
333
+ * refusal for a git dependency, which is the other code the approval path can
334
+ * clear.
335
+ */
336
+ function failedOnBlockedBuilds(result: { exitCode: number | null; stdout: string; stderr: string }): boolean {
337
+ const code = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code
338
+ return code === 'ignored-builds' || code === 'git-prepare-not-allowed'
339
+ }
340
+
318
341
  /**
319
342
  * Register the market's HTTP routes.
320
343
  * @param host - Acquired webServer + shell services.
@@ -1266,6 +1289,11 @@ export function mountMarketRoutes(
1266
1289
  // and the page then tells the user to refresh, which is the one thing
1267
1290
  // that cannot help.
1268
1291
  const native = holdsNativeAddon(config.profile, name, activeProfileDir)
1292
+ // Asked BEFORE the removal, like `native` above and for the same reason:
1293
+ // the row ids come from the package's own bundle patch, which the remove
1294
+ // deletes. Read afterwards this returns [] and the rows survive the
1295
+ // package as boot-time orphans (#799).
1296
+ const ownedRows = rowIdsForPackage(host, activeProfileDir, name)
1269
1297
  const result = await runPlugin(config.profile, ['remove', name])
1270
1298
  if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
1271
1299
  return { ok: false, hot: false, detail: failureDetail(result) }
@@ -1293,7 +1321,7 @@ export function mountMarketRoutes(
1293
1321
  if (native) {
1294
1322
  logEvent('info', 'uninstall', `${name} ships or depends on a native addon; a restart is needed before it can be installed again`)
1295
1323
  }
1296
- removeRowBlocks(userPatchPath, rowIdsForPackage(host, activeProfileDir, name))
1324
+ removeRowBlocks(userPatchPath, ownedRows)
1297
1325
  disabled.delete(name)
1298
1326
  replacedWhileLive.delete(name)
1299
1327
  removeFromGroups({ groups, groupOrder }, name)
@@ -3361,6 +3389,15 @@ export function mountMarketRoutes(
3361
3389
  // "no restart button" is the state #229 reported as broken.
3362
3390
  supervisor: detectedSupervisor(),
3363
3391
  debugger: detectedDebugger(),
3392
+ // Whether a restart asked for from THIS page could pass the route's
3393
+ // fence (#782). Per request, not per config: behind a reverse proxy
3394
+ // the POST arrives over the same path as this GET, so it fails for
3395
+ // the same reasons — and the banner used to render a button that
3396
+ // answered 403 every time, with nothing saying so in advance. It is
3397
+ // a prediction: the route still runs the whole fence on the POST.
3398
+ // Deliberately NOT folded into `restart`, which is also what the
3399
+ // settings page reads and writes back as the user's own choice.
3400
+ restartReachable: restartReachableFrom(request),
3364
3401
  selfManaged: installed.dshmarket !== undefined || installed['dsh-market'] !== undefined,
3365
3402
  // Whether the host took the market's settings namespace (#677).
3366
3403
  // `unsupported-by-host` is 0.1.7 and newer, where settings come from
@@ -4234,6 +4271,21 @@ sendJson(response, 200, { updates })
4234
4271
  // declaration is unchanged; a failed run restores the manifest from
4235
4272
  // the snapshot above.
4236
4273
  //
4274
+ // `reresolveInPlace` is not the only shape whose diff is empty.
4275
+ // When the manifest holds the spelling pnpm itself wrote for a
4276
+ // GitHub source — `git+https://owner/repo.git` — and the market
4277
+ // sends the `github:owner/repo` shortcut, the target differs from
4278
+ // the specifier but pnpm still gives the specifier back unchanged,
4279
+ // so the host answers `ambiguous-install` again (#803; measured on
4280
+ // pnpm 11.7.0, the version DSH Desktop bundles, against the
4281
+ // reporter's own spec pair). One question covers both: pnpm leaves
4282
+ // the specifier as it was, so the diff the host reads is empty.
4283
+ //
4284
+ // The pin is the market's own write to a foreign pipeline, so it is
4285
+ // scoped to the host that reads the manifest itself. A host that
4286
+ // takes market options lets the market verify its own run, and gets
4287
+ // no extra manifest write.
4288
+ //
4237
4289
  // A `#path:` spec needs the local pin instead of the host's exact
4238
4290
  // rollback target: `gitTargetAtCommit` refuses the `&` that carries
4239
4291
  // a selector beside a commit, because dsh-cli's target grammar has
@@ -4243,7 +4295,9 @@ sendJson(response, 200, { updates })
4243
4295
  const pinValue = gitRollbackTarget ?? (hasGitSubpath && beforeCommit !== null
4244
4296
  ? pinSpecToCommit(spec, beforeCommit)
4245
4297
  : null)
4246
- const pinnedForIdentification = reresolveInPlace && !inPlaceUpdate
4298
+ const identifiesInstalledByManifestDiff = !marketFlags
4299
+ const pinnedForIdentification = isGit && !restore
4300
+ && identifiesInstalledByManifestDiff
4247
4301
  && pinValue !== null && pinValue !== spec
4248
4302
  if (pinnedForIdentification) {
4249
4303
  declareProfileDependency(config.profile, name, pinValue, activeProfileDir)
@@ -4678,7 +4732,10 @@ sendJson(response, 200, { updates })
4678
4732
  // build-required dep fails the add with ERR_PNPM_IGNORED_BUILDS.
4679
4733
  // Reporting the blocked packages here gives the client the same
4680
4734
  // approve-and-retry banner the install flow has had since #6.
4681
- const ignoredBuilds = ok || cancelled ? undefined : blockedBuilds(result)
4735
+ // Same rule as the install route (#754): a failure that merely
4736
+ // MENTIONS a skipped build is not a failure ABOUT one, and offering
4737
+ // to approve it sends the user to press a button that cannot help.
4738
+ const ignoredBuilds = ok || cancelled || !failedOnBlockedBuilds(result) ? undefined : blockedBuilds(result)
4682
4739
  if (ok) clearBrokenPlugin(name)
4683
4740
  // A package still installed but no longer in dsh.profile.bundles
4684
4741
  // after this run stops loading and says nothing (#720). The market
@@ -5402,6 +5459,12 @@ sendJson(response, 200, { updates })
5402
5459
  // `hot` would send the user to a page refresh, which is the one
5403
5460
  // thing that cannot help.
5404
5461
  const heldNativeAddon = holdsNativeAddon(config.profile, name, activeProfileDir)
5462
+ // And once more, for the same reason: `rowIdsForPackage` names the
5463
+ // rows from the package's own bundle patch, which the remove below
5464
+ // deletes. Asked afterwards it answers [] on exactly the uninstall
5465
+ // it is meant to clean up after, and the rows outlive the package
5466
+ // as boot-time orphans (#799).
5467
+ const ownedRows = rowIdsForPackage(host, activeProfileDir, name)
5405
5468
  const result = await runPlugin(config.profile, ['remove', name])
5406
5469
  const cancelled = result.cancelled
5407
5470
  const ok = result.exitCode === 0 && !result.timedOut && !cancelled
@@ -5465,7 +5528,7 @@ sendJson(response, 200, { updates })
5465
5528
  // Patch-layer rows must not survive the remove either: a
5466
5529
  // `- id: X` + `disabled: true` row for a package that no longer
5467
5530
  // mounts is a boot-time orphan (port of dsh-plugin-hub).
5468
- removeRowBlocks(userPatchPath, rowIdsForPackage(host, activeProfileDir, name))
5531
+ removeRowBlocks(userPatchPath, ownedRows)
5469
5532
  // The disable list must not keep a removed plugin: a later
5470
5533
  // reinstall starts enabled. Group memberships follow the same
5471
5534
  // rule so no group toggle ever targets a ghost member.
@@ -6031,7 +6094,10 @@ sendJson(response, 200, { updates })
6031
6094
  if (ok) clearBrokenPlugin(entry.name)
6032
6095
  logEvent(ok || cancelled ? 'info' : 'error', 'install',
6033
6096
  `${target} exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''}${cancelled ? ' CANCELLED' : ''}${ok ? ` hot=${String(hot)}` : cancelled ? '' : ` err=${failureDetail(result)}`}`)
6034
- const ignoredBuilds = blockedBuilds(result)
6097
+ // On success a skipped build is a notice (#6); after a FAILURE the
6098
+ // list is what offers the approve-and-retry banner, so it is sent
6099
+ // only when the blocked build IS why the run failed (#754).
6100
+ const ignoredBuilds = ok || cancelled || failedOnBlockedBuilds(result) ? blockedBuilds(result) : undefined
6035
6101
  sendJson(response, ok || cancelled ? 200 : result.busy === true ? 409 : 502, {
6036
6102
  ok,
6037
6103
  cancelled: cancelled || undefined,