dshmarket 1.59.0 → 1.61.0

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
@@ -7139,6 +7139,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7139
7139
  * real switch state so hand-edited cordis.patch.yml toggles are visible.
7140
7140
  */
7141
7141
  const [patchDisabledNames, setPatchDisabledNames] = (0, react.useState)([]);
7142
+ /** Bundle packages DSH's own plugin page turned off by leaving them out of dsh.profile.bundles (#696). */
7143
+ const [unbundledNames, setUnbundledNames] = (0, react.useState)([]);
7142
7144
  const [groups, setGroups] = (0, react.useState)({});
7143
7145
  const [groupOrder, setGroupOrder] = (0, react.useState)([]);
7144
7146
  /** Installed-tab sub-view: flat list or groups (All-plugins was removed —
@@ -7309,6 +7311,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7309
7311
  }
7310
7312
  if (body.notes !== null && typeof body.notes === "object" && !Array.isArray(body.notes)) setNotes(body.notes);
7311
7313
  if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled);
7314
+ if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled);
7312
7315
  if (body.groups && typeof body.groups === "object") setGroups(body.groups);
7313
7316
  if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder);
7314
7317
  if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url) => typeof url === "string"));
@@ -7324,7 +7327,15 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7324
7327
  (0, react.useMemo)(() => new Set(disabledNames), [disabledNames]);
7325
7328
  const favoriteUrlSet = (0, react.useMemo)(() => new Set(favoriteUrls), [favoriteUrls]);
7326
7329
  /** Effective switch state: market disable list ∪ user-patch-layer disables. */
7327
- const effectiveDisabledSet = (0, react.useMemo)(() => /* @__PURE__ */ new Set([...disabledNames, ...patchDisabledNames]), [disabledNames, patchDisabledNames]);
7330
+ const effectiveDisabledSet = (0, react.useMemo)(() => /* @__PURE__ */ new Set([
7331
+ ...disabledNames,
7332
+ ...patchDisabledNames,
7333
+ ...unbundledNames
7334
+ ]), [
7335
+ disabledNames,
7336
+ patchDisabledNames,
7337
+ unbundledNames
7338
+ ]);
7328
7339
  (0, react.useEffect)(() => {
7329
7340
  if (tab !== "themes" && themesFullscreen) setThemesFullscreen(false);
7330
7341
  }, [tab, themesFullscreen]);
package/lib/check.js CHANGED
@@ -759,7 +759,19 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
759
759
  // fatal verdict and rolled back a good update (#369) — while `dsh
760
760
  // --dump-config` on the same profile exited 0. Unknown has to read as
761
761
  // unknown; the profile's own bundles are still judged normally.
762
- if (hostProvided) {
762
+ //
763
+ // The same holds for any official bundle while the installation itself
764
+ // cannot be located (#676): a desktop build ships more in-box bundles
765
+ // than the three named in INBOX_BUNDLES —
766
+ // `@deepseek-ai/dsh-experimental-agent-team-profile` among them — and a
767
+ // fixed list, or an install-path shape, cannot know which. Two
768
+ // reporters saw that bundle called "not installed — will fail to boot"
769
+ // while its three entries were active in the running host. Only
770
+ // DeepSeek publishes under `@deepseek-ai/`, so while the installation
771
+ // is out of sight such a bundle is unknown, not missing. When the
772
+ // installation IS located and the bundle is in neither it nor the
773
+ // profile, the fatal verdict below still applies.
774
+ if (hostProvided || (dshInstallDir === null && name.startsWith('@deepseek-ai/'))) {
763
775
  layer.error = null;
764
776
  layer.unresolvedInbox = true;
765
777
  return layer;
package/lib/install.js CHANGED
@@ -4,8 +4,9 @@
4
4
  * update staleness detection. Every function takes the plugin runner as a
5
5
  * parameter so tests can substitute a recording fake.
6
6
  */
7
- import { existsSync } from 'node:fs';
8
- import { join } from 'node:path';
7
+ import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs';
8
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
9
+ import { findDshInstallDir } from './dsh-install.js';
9
10
  import { classifyPnpmFailure, HOST_NAMESPACE_RE } from './pnpm-compat.js';
10
11
  import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.js';
11
12
  import { logEvent } from './log.js';
@@ -176,6 +177,7 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
176
177
  // construction: directories are only removed when their owning pid is
177
178
  // gone (the name carries it), so a live download is never touched.
178
179
  await cleanOrphanedStore(run, profile);
180
+ const diagnostics = diagnosticsTail(result);
179
181
  const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode);
180
182
  if (failure !== null) {
181
183
  result = {
@@ -184,6 +186,13 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
184
186
  ...(failure.replaceOutput === true ? { stdout: '' } : {}),
185
187
  };
186
188
  }
189
+ else if (diagnostics !== null) {
190
+ // The dsh CLI redirects the whole pnpm run into a file and leaves one
191
+ // line on stderr: `dsh: pnpm failed; diagnostics: <path>` (its own
192
+ // literal message). Everything a user or a report needs is in that
193
+ // file, so show its tail rather than the one line (#672).
194
+ result = { ...result, stderr: `${result.stderr}\n\n--- dsh diagnostics (${diagnostics.path}) ---\n${diagnostics.text}` };
195
+ }
187
196
  else if (result.pnpmError !== undefined && result.pnpmError !== '') {
188
197
  // Nothing matched, but pnpm DID say what went wrong — in its ndjson
189
198
  // stream, which never reaches stderr. Without this the user is shown
@@ -200,6 +209,54 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
200
209
  }
201
210
  return result;
202
211
  }
212
+ /**
213
+ * The tail of the diagnostics file the dsh CLI pointed at, when it pointed at
214
+ * one (#672).
215
+ *
216
+ * `dsh plugin` writes pnpm's entire output to a file and prints only
217
+ * `dsh: pnpm failed; diagnostics: <path>`. For a failure the market cannot
218
+ * classify, that line is all it has — the reasons people reported (#244,
219
+ * #192, #138) were all "the UI shows one unhelpful line" for causes that
220
+ * were written down somewhere the UI never looked.
221
+ *
222
+ * Bounded on purpose: absolute paths only, a regular file, and at most the
223
+ * last {@link DIAGNOSTICS_TAIL_BYTES}. The path comes from our own child, but
224
+ * the market only ever needs the end of a log, and reading an arbitrary
225
+ * amount of an arbitrary file is not worth anything it could add.
226
+ *
227
+ * @returns the path and the text, or null when the output names no readable
228
+ * diagnostics file.
229
+ */
230
+ export function diagnosticsTail(result) {
231
+ const match = /(?:^|\n)\s*dsh: [^\n]*diagnostics:\s*(\S+)\s*$/m.exec(result.stderr)
232
+ ?? /(?:^|\n)\s*dsh: [^\n]*diagnostics:\s*(\S+)\s*$/m.exec(result.stdout);
233
+ if (match === null)
234
+ return null;
235
+ const path = match[1];
236
+ if (!isAbsolute(path))
237
+ return null;
238
+ try {
239
+ if (!statSync(path).isFile())
240
+ return null;
241
+ const size = statSync(path).size;
242
+ const start = Math.max(0, size - DIAGNOSTICS_TAIL_BYTES);
243
+ const handle = openSync(path, 'r');
244
+ try {
245
+ const buffer = Buffer.alloc(Math.min(DIAGNOSTICS_TAIL_BYTES, size));
246
+ readSync(handle, buffer, 0, buffer.length, start);
247
+ const text = buffer.toString('utf8').trim();
248
+ return text === '' ? null : { path, text };
249
+ }
250
+ finally {
251
+ closeSync(handle);
252
+ }
253
+ }
254
+ catch {
255
+ return null;
256
+ }
257
+ }
258
+ /** How much of a diagnostics file is worth showing. */
259
+ const DIAGNOSTICS_TAIL_BYTES = 8192;
203
260
  /**
204
261
  * Whether pnpm never started at all, so the profile cannot have been touched.
205
262
  *
@@ -307,7 +364,7 @@ export async function retargetCollections(run, profile, before, target, explicit
307
364
  * profile at all, which is a broken plugin-command channel rather than
308
365
  * anything wrong with the plugin (#258).
309
366
  */
310
- export async function validateAddedPlugins(run, profile, before, explicitDir) {
367
+ export async function validateAddedPlugins(run, profile, before, explicitDir, hostDirectory) {
311
368
  const dir = profileDir(profile, explicitDir);
312
369
  const addedNow = Object.keys(readInstalled(profile, dir)).filter(n => !before.has(n));
313
370
  const keep = [];
@@ -322,7 +379,7 @@ export async function validateAddedPlugins(run, profile, before, explicitDir) {
322
379
  // ship no entry of their own (#103) and must not be uninstalled here.
323
380
  if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
324
381
  removedBroken.push(n);
325
- await removeAndReconcile(run, profile, dir, n);
382
+ await removeAndReconcile(run, profile, dir, n, hostDirectory);
326
383
  continue;
327
384
  }
328
385
  const clash = conflictingEntryIds(dir, n, existingBundles);
@@ -331,7 +388,7 @@ export async function validateAddedPlugins(run, profile, before, explicitDir) {
331
388
  conflicts.push(...clash.map(hit => ({ name: n, ...hit })));
332
389
  removedBroken.push(n);
333
390
  logEvent('error', 'install', `${n}: loader entry id conflict with ${clash[0].owner} (${clash.map(hit => hit.id).join(', ')}) — removing, it would break the next boot`);
334
- await removeAndReconcile(run, profile, dir, n);
391
+ await removeAndReconcile(run, profile, dir, n, hostDirectory);
335
392
  continue;
336
393
  }
337
394
  keep.push(n);
@@ -356,11 +413,14 @@ export async function validateAddedPlugins(run, profile, before, explicitDir) {
356
413
  * @param profile - the profile name for manifest writes.
357
414
  * @param dir - the profile directory the validation reads.
358
415
  * @param name - the package being removed.
416
+ * @param hostDirectory - the DSH host deployment directory whose node_modules
417
+ * may hold a bridge link for this package (#662); resolved when omitted.
359
418
  */
360
- async function removeAndReconcile(run, profile, dir, name) {
419
+ async function removeAndReconcile(run, profile, dir, name, hostDirectory = findDshInstallDir()) {
361
420
  const result = await run(profile, ['remove', name]);
362
421
  const gone = !existsSync(join(dir, 'node_modules', name, 'package.json'));
363
422
  if (gone) {
423
+ removeDanglingHostBridge(name, dir, hostDirectory);
364
424
  if (dropFromManifest(profile, name, dir)) {
365
425
  logEvent('error', 'install', `${name}: remove ${result.exitCode === 0 ? 'skipped the manifest reconcile' : `failed (exit ${String(result.exitCode)})`} but the package is gone from disk — dropped its dependency/bundle rows so the next boot stays loadable`);
366
426
  }
@@ -370,6 +430,99 @@ async function removeAndReconcile(run, profile, dir, name) {
370
430
  logEvent('error', 'install', `${name}: remove failed (exit ${String(result.exitCode)})${result.timedOut ? ' timed out' : ''}${result.cancelled ? ' cancelled' : ''} and the package is still installed — its rows stay in the manifest; retry the uninstall`);
371
431
  }
372
432
  }
433
+ /**
434
+ * The node_modules root of the DSH host deployment `directory` belongs to.
435
+ *
436
+ * CLI layouts install the host as `<prefix>/node_modules/@deepseek-ai/dsh`,
437
+ * so the shared root is two dirname steps up; a flat Desktop layout keeps
438
+ * the host package at the deployment root (#662's
439
+ * `<desktop-app>\dependencies\dsh`), with its pnpm-managed node_modules
440
+ * directly beside its package.json. `dshHostInfo()` already distinguishes
441
+ * the two — this is pure path arithmetic on whichever directory it returned.
442
+ */
443
+ export function hostNodeModulesRoot(directory) {
444
+ const segments = directory.split(/[/\\]+/).filter(segment => segment !== '');
445
+ const n = segments.length;
446
+ if (n >= 3 && segments[n - 3].toLowerCase() === 'node_modules' && segments[n - 2].toLowerCase() === '@deepseek-ai') {
447
+ return resolve(dirname(dirname(directory)));
448
+ }
449
+ return resolve(directory, 'node_modules');
450
+ }
451
+ /**
452
+ * Normalize a link target for path comparison: restore the UNC device form
453
+ * (`\\?\UNC\server\share` back to `\\server\share` — stripped of its prefix
454
+ * it is no longer absolute and resolve() would re-root it against the
455
+ * cwd), then remove the NT device prefixes `\\?\` and the subst-style
456
+ * `\??\` mklink stores. Measured on Node 24/win32: readlinkSync returns
457
+ * the plain absolute path, so these branches only matter for links created
458
+ * outside Node — but a comparison must not silently miss because of them.
459
+ */
460
+ export function normalizedLinkTarget(target) {
461
+ return target
462
+ .replace(/^\\\\\?\\UNC\\/, '\\\\')
463
+ .replace(/^(?:\\\\\?\\|\\\?\?\\)/, '');
464
+ }
465
+ /**
466
+ * Whether a link target names the profile's copy of a package. Junction
467
+ * targets keep the case they were created with, so the comparison is
468
+ * case-insensitive on win32, where the filesystem itself is.
469
+ */
470
+ function pointsAtProfilePackage(target, expected) {
471
+ const left = resolve(normalizedLinkTarget(target));
472
+ const right = resolve(expected);
473
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
474
+ }
475
+ /**
476
+ * Remove the host-side bridge link a confirmed uninstall leaves dangling
477
+ * (#662). The official boot projects profile packages into the host
478
+ * deployment's node_modules as links (Junction or SymbolicLink — lstat
479
+ * reports both as symlinks) and never reclaims them, and `dsh plugin
480
+ * remove` knows nothing about them, so without this the link outlives the
481
+ * package it pointed at and every tool that lstats its way through
482
+ * node_modules (rg first among them) fails on it.
483
+ *
484
+ * The gate is deliberately total: only `<host node_modules>/<name>` is ever
485
+ * touched, only when that entry is a link whose normalized target is
486
+ * exactly this profile's copy of `name`, and only when that copy is really
487
+ * gone — a live bridge for a package that is still installed must survive.
488
+ * A null `hostDirectory` (no host locatable — a plain `dsh web` from a
489
+ * global install) is a documented no-op.
490
+ *
491
+ * @returns whether a dangling bridge was removed. Never throws: the removal
492
+ * this cleans up after already succeeded, and a cleanup failure must not
493
+ * fail the uninstall that triggered it.
494
+ */
495
+ export function removeDanglingHostBridge(name, profileDirectory, hostDirectory) {
496
+ if (hostDirectory === null)
497
+ return false;
498
+ const bridge = join(hostNodeModulesRoot(hostDirectory), name);
499
+ const unlinked = join(profileDirectory, 'node_modules', name);
500
+ try {
501
+ if (!lstatSync(bridge).isSymbolicLink())
502
+ return false;
503
+ // The gone-check mirrors removeAndReconcile's manifest truth: a package
504
+ // whose package.json is gone is uninstalled even if an empty directory
505
+ // lingered behind, and its bridge is exactly the dangling link #662 is
506
+ // about.
507
+ if (!pointsAtProfilePackage(readlinkSync(bridge), unlinked) || existsSync(join(unlinked, 'package.json')))
508
+ return false;
509
+ // No `recursive`: rmSync on a link unlinks the link itself only
510
+ // (measured on win32/Node 24 for live and dangling junctions and dir
511
+ // symlinks), and leaving it off keeps a race that swaps the link for a
512
+ // real directory from ever deleting that directory's tree.
513
+ rmSync(bridge, { force: true });
514
+ }
515
+ catch (error) {
516
+ // ENOENT is the ordinary "no bridge there" answer (and a lost race
517
+ // while unlinking); anything else is worth a line in the log.
518
+ if (error.code !== 'ENOENT') {
519
+ logEvent('warn', 'uninstall', `${name}: host bridge cleanup skipped — ${error instanceof Error ? error.message : String(error)}`);
520
+ }
521
+ return false;
522
+ }
523
+ logEvent('info', 'uninstall', `${name}: removed the dangling host bridge the boot projection left at ${bridge}`);
524
+ return true;
525
+ }
373
526
  /**
374
527
  * Group flat `{id, owner}` conflict hits by the installed plugin that owns
375
528
  * them. What the user has to decide is which PLUGINS to uninstall, not which
@@ -313,6 +313,21 @@ export function classifyPnpmFailure(output, exitCode) {
313
313
  // pnpm's FETCHER, before anything lands in node_modules — so the package
314
314
  // the user must approve is not installed yet, and pnpm's own hint names a
315
315
  // commit-pinned codeload URL that changes on every push.
316
+ // #701: pnpm 12's native engine (pnpm-native) aborting on an allocation it
317
+ // cannot get — "memory allocation of 5368709120 bytes failed", Windows exit
318
+ // 3221226505 (0xC0000409, how a Rust abort ends there). Reported with an
319
+ // A/B: on pnpm 12.5.1 every run with `autoInstallPeers: false` in the
320
+ // workspace file (DSH writes it into every profile) aborted at ~5 GB peak
321
+ // RSS, every run without it passed at ~80 MB, with free memory to spare.
322
+ // Nothing about the plugin being installed; not something a retry of the
323
+ // same command changes on the reporter's data, so none is attempted.
324
+ if (/memory allocation of \d+ bytes failed/.test(output) || exitCode === 3221226505) {
325
+ return {
326
+ code: 'native-oom',
327
+ recoverable: false,
328
+ message: 'pnpm 12 的原生引擎在处理这个 profile 时耗尽了内存并中止——和要安装的插件无关。在 pnpm 修复之前,请改用 pnpm 11(npm install -g pnpm@11)后再试。 / pnpm 12\'s native engine ran out of memory on this profile and aborted — the plugin being installed is not the cause. Until pnpm fixes it, switch to pnpm 11 (npm install -g pnpm@11) and try again.',
329
+ };
330
+ }
316
331
  // #698: pnpm 10.26+ and 11.0–11.5 read an allowBuilds key as
317
332
  // `name@<version union>`, so a git or archive source there fails the WHOLE
318
333
  // workspace file — every pnpm command in the profile, not the one plugin.
package/lib/routes.js CHANGED
@@ -14,7 +14,7 @@ import { load as loadYaml } from 'js-yaml';
14
14
  import { forgetCatalog, loadRegistry, pluginCategories } from './registry.js';
15
15
  import { cleanHotDir, hotMount, hotUnmount, listHotMounts, MAX_FAVORITES, MAX_NOTE, mountClientOnlyDeps, purgeMarketState, readMarketState, writeMarketState, } from './hot.js';
16
16
  import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMembers } from './groups.js';
17
- import { dshHostInfo } from './dsh-install.js';
17
+ import { dshHostInfo, findDshInstallDir } from './dsh-install.js';
18
18
  import { deriveHostCompatibility, DiscoveryManifestIndex } from './discovery-compatibility.js';
19
19
  import { configurePersistentLog, exportLogs, logEvent, readPersistentLog } from './log.js';
20
20
  import { marketFetch } from './net.js';
@@ -30,7 +30,7 @@ import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } fro
30
30
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.js';
31
31
  import { trialValidate } from './trial.js';
32
32
  import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.js';
33
- import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
33
+ import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.js';
34
34
  import { classifyPnpmFailure } from './pnpm-compat.js';
35
35
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel } from './channels.js';
36
36
  import { asRegion, githubProxyManaged, normalizeGithubProxy, REGIONS, routesFor, setActiveRegion, setCustomGithubProxy, } from './regions.js';
@@ -177,6 +177,16 @@ function packageHasClientPart(profileDirectory, name) {
177
177
  * the package, and the key written is the one pnpm printed, never text from
178
178
  * the request.
179
179
  */
180
+ /** Whether an installed package's manifest declares a bundle. */
181
+ function declaresBundle(profileDirectory, name) {
182
+ try {
183
+ const manifest = JSON.parse(readFileSync(join(profileDirectory, 'node_modules', name, 'package.json'), 'utf8'));
184
+ return manifest.dsh?.bundle !== undefined;
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
180
190
  const prepareRefusals = new Map();
181
191
  function blockedBuilds(result) {
182
192
  if (Array.isArray(result.ignoredBuilds) && result.ignoredBuilds.length > 0)
@@ -365,6 +375,34 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
365
375
  marketState.githubProxy = fresh.githubProxy;
366
376
  setCustomGithubProxy(fresh.githubProxy ?? null);
367
377
  }
378
+ /**
379
+ * Follow an enable made OUTSIDE the market (#696).
380
+ *
381
+ * The market keeps its own disable list in state.json, and the self-heal
382
+ * guard below and the boot replay put any plugin on that list back down.
383
+ * DSH's own Settings → Plugins page enables a row by flipping it to
384
+ * `disabled: false` in the shared patch layer — it has never heard of
385
+ * state.json — so its enable was silently undone within a second and again
386
+ * on every boot, and the official switch looked broken.
387
+ *
388
+ * The patch layer is the one truth both managers share. A row explicitly
389
+ * enabled there while no row of the same package is disabled can only be a
390
+ * newer decision than the market's list (the market removes a name from
391
+ * its list BEFORE writing its own enable row), so the list follows it.
392
+ *
393
+ * @returns true when the package was re-enabled elsewhere and has been
394
+ * dropped from the market's disable list.
395
+ */
396
+ function followOutsideEnable(name) {
397
+ const patch = readUserPatchState(userPatchPath);
398
+ const flags = packagePatchFlags(host, activeProfileDir, [name], patch);
399
+ if (!flags.forced.includes(name) || flags.disabled.includes(name))
400
+ return false;
401
+ disabled.delete(name);
402
+ writeMarketState(activeProfileDir, { disabled, groups, groupOrder });
403
+ logEvent('info', 'toggle', `${name}: its rows were re-enabled outside the market (#696) — following that instead of switching it back off`);
404
+ return true;
405
+ }
368
406
  // Client-only packages (dsh.client without dsh.bundle) are invisible to the
369
407
  // bundle layer in every boot; the market shim-mounts them so their client
370
408
  // bundles are actually served.
@@ -375,7 +413,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
375
413
  // switched away from get live-disabled again (bundle trees are
376
414
  // in-memory, so the disable never persists on its own). Client-only
377
415
  // shims for disabled plugins were already skipped by mountClientOnlyDeps.
378
- for (const name of disabled) {
416
+ for (const name of [...disabled]) {
417
+ if (followOutsideEnable(name))
418
+ continue;
379
419
  if (await themes.setEntryDisabled(name, true))
380
420
  logEvent('info', 'boot', `plugin kept off: ${name}`);
381
421
  }
@@ -385,7 +425,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
385
425
  // up for a plugin the user switched off, put it back down.
386
426
  host.on?.('internal/plugin', (fiber) => {
387
427
  const name = fiber.entry?.options?.name;
388
- if (name !== undefined && disabled.has(name))
428
+ if (name !== undefined && disabled.has(name) && !followOutsideEnable(name))
389
429
  void themes.setEntryDisabled(name, true);
390
430
  });
391
431
  let installing = false;
@@ -926,6 +966,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
926
966
  // with two activation sources must not have the second one skipped
927
967
  // because the first succeeded.
928
968
  const unmounted = await hotUnmount(name);
969
+ // #662: the removal is confirmed — drop the host bridge link the boot
970
+ // projection may have left pointing at the now-gone package.
971
+ removeDanglingHostBridge(name, activeProfileDir, config.dshInstallDir ?? findDshInstallDir());
929
972
  const entryDisabled = await themes.setEntryDisabled(name, true);
930
973
  const hot = (unmounted || entryDisabled) && !native;
931
974
  if (native) {
@@ -1806,8 +1849,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1806
1849
  return typeof manifest === 'object' && manifest !== null
1807
1850
  && manifest.dsh !== undefined;
1808
1851
  };
1852
+ // A package that declares a bundle but is not in dsh.profile.bundles
1853
+ // is not composed at boot: DSH's own plugin page turns a package off
1854
+ // by removing it from that list, and nothing the market recorded
1855
+ // says so (#696). Reported as off, unless it is live right now — a
1856
+ // hot mount from this session still runs until the next restart.
1857
+ const composedBundles = new Set(readProfileBundles(activeProfileDir));
1858
+ const unbundled = Object.keys(installed).filter(name => {
1859
+ if (INBOX_BUNDLES.has(name) || composedBundles.has(name) || live.has(name))
1860
+ return false;
1861
+ const manifest = installedManifests.get(name);
1862
+ return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined;
1863
+ });
1809
1864
  for (const name of Object.keys(installed)) {
1810
- const result = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name)), replacedWhileLive.has(name));
1865
+ const result = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name) || unbundled.includes(name)), replacedWhileLive.has(name));
1811
1866
  // A package with no dsh surface of its own, outside the bundle
1812
1867
  // layer, that another installed package declares, is that package's
1813
1868
  // library rather than a plugin that failed to start (#634). Saying
@@ -1843,6 +1898,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1843
1898
  favorites: readMarketState(activeProfileDir).favorites ?? [],
1844
1899
  patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts },
1845
1900
  patchDisabled: patchFlags.disabled,
1901
+ unbundled,
1846
1902
  patchForced: patchFlags.forced,
1847
1903
  bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
1848
1904
  });
@@ -2257,15 +2313,25 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
2257
2313
  // disabled) is NOT dropped: #147 requires disabling it to leave the
2258
2314
  // neighbour live, and the e2e fixture-cross re-enable breaks otherwise.
2259
2315
  const disablesOthers = carrierDisableIds(activeProfileDir, name);
2316
+ // Turned off by DSH's own plugin page, which removes a package from
2317
+ // dsh.profile.bundles (#696): enabling it here has to put it back,
2318
+ // or the patch rows flip, the switch reads on, and nothing composes
2319
+ // it on the next boot. Unlike a carrier this does NOT force a
2320
+ // restart: the enable below still brings it up in this process, and
2321
+ // whether a restart is needed is whether it is live afterwards.
2322
+ const reBundle = enabled && disablesOthers.length === 0 && !INBOX_BUNDLES.has(name)
2323
+ && declaresBundle(activeProfileDir, name) && !readProfileBundles(activeProfileDir).includes(name);
2260
2324
  const isCarrier = disablesOthers.length > 0;
2261
2325
  let bundleSwitch = { ok: true, reason: null };
2262
- if (isCarrier) {
2326
+ if (isCarrier || reBundle) {
2263
2327
  try {
2264
2328
  if (enabled)
2265
2329
  addProfileBundle(activeProfileDir, name);
2266
2330
  else
2267
2331
  removeProfileBundle(activeProfileDir, name);
2268
- logEvent('info', 'toggle', `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`);
2332
+ logEvent('info', 'toggle', reBundle
2333
+ ? `${name}: re-added to dsh.profile.bundles, which another manager had removed it from (#696)`
2334
+ : `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`);
2269
2335
  }
2270
2336
  catch (error) {
2271
2337
  bundleSwitch = { ok: false, reason: error instanceof Error ? error.message : String(error) };
@@ -3935,6 +4001,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
3935
4001
  // has just been removed. Only rows belonging to packages on the
3936
4002
  // market's own disable list are touched — a hand-written row is
3937
4003
  // the user's, not ours.
4004
+ // #662 first, while the filesystem is the only thing touched:
4005
+ // the market's own host bridge dangles the moment the remove
4006
+ // succeeds, and this route is the last code of ours to run.
4007
+ removeDanglingHostBridge(selfName, activeProfileDir, config.dshInstallDir ?? findDshInstallDir());
3938
4008
  const purge = body.purge === true;
3939
4009
  const restored = [];
3940
4010
  if (purge) {
@@ -4224,6 +4294,13 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
4224
4294
  sendJson(response, 400, { error: 'the market cannot uninstall itself; use the dsh CLI' });
4225
4295
  return;
4226
4296
  }
4297
+ // The bridge cleanup this route performs joins the package name
4298
+ // into a host node_modules path (#662); a hand-edited manifest
4299
+ // carrying `../../evil` must not escape that join.
4300
+ if (!NPM_NAME_RE.test(name)) {
4301
+ sendJson(response, 400, { error: 'plugin is not installed' });
4302
+ return;
4303
+ }
4227
4304
  if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
4228
4305
  sendJson(response, 400, { error: 'plugin is not installed' });
4229
4306
  return;
@@ -4309,6 +4386,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
4309
4386
  let hot = false;
4310
4387
  if (ok || halfGone) {
4311
4388
  invalidateUpdates();
4389
+ // #662: the removal is final (confirmed exit or reconciled
4390
+ // from disk truth) — the host node_modules bridge the boot
4391
+ // projection left for this package must not outlive it.
4392
+ removeDanglingHostBridge(name, activeProfileDir, config.dshInstallDir ?? findDshInstallDir());
4312
4393
  hot = await hotUnmount(name);
4313
4394
  // Bundle-layer plugins never hot-mount, but their loader entry
4314
4395
  // is still LIVE in this process — after the remove deleted the
@@ -4632,6 +4713,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
4632
4713
  // keep their partial state on purpose (the user sees the diff
4633
4714
  // and decides).
4634
4715
  const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir);
4716
+ // The lockfile too (#701): pnpm writes it before it links, so a
4717
+ // run that dies in between — a native crash, a kill — leaves a
4718
+ // lock that names a package the manifest never got. The update
4719
+ // route has always restored both; a fresh install restored only
4720
+ // the manifest and left the half for the next pnpm run to trip on.
4721
+ const lockfileBefore = captureProfileLockfile();
4635
4722
  const pinned = target === pinnedTarget && pinnedTarget !== plainTarget;
4636
4723
  let result = await (pinned ? runPluginKeepingReleaseAge : runPlugin)(config.profile, ['add', target]);
4637
4724
  // Two ways a pinned add can fail that a bare add would not, and
@@ -4666,6 +4753,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
4666
4753
  const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
4667
4754
  if (rolledBack.length > 0)
4668
4755
  logEvent('warn', 'install', `${target}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`);
4756
+ if (lockfileBefore.ok) {
4757
+ const lock = restoreProfileLockfile(lockfileBefore.snapshot);
4758
+ if (!lock.ok)
4759
+ logEvent('warn', 'install', `${target}: could not restore pnpm-lock.yaml after the failed run: ${lock.detail ?? 'unknown'}`);
4760
+ }
4669
4761
  }
4670
4762
  let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
4671
4763
  const cancelDiff = cancelled ? changedSince(beforeSpecs) : null;
@@ -102,6 +102,31 @@ export declare function isUnpublishedHostPeer(pkg: string | undefined, profile:
102
102
  export declare function withHoistRecovery(run: PluginRunner, profile: string, pluginArgs: string[], profileDirectory?: string, options?: {
103
103
  releaseAgeBypass?: boolean;
104
104
  }): Promise<InstallResult>;
105
+ /**
106
+ * The tail of the diagnostics file the dsh CLI pointed at, when it pointed at
107
+ * one (#672).
108
+ *
109
+ * `dsh plugin` writes pnpm's entire output to a file and prints only
110
+ * `dsh: pnpm failed; diagnostics: <path>`. For a failure the market cannot
111
+ * classify, that line is all it has — the reasons people reported (#244,
112
+ * #192, #138) were all "the UI shows one unhelpful line" for causes that
113
+ * were written down somewhere the UI never looked.
114
+ *
115
+ * Bounded on purpose: absolute paths only, a regular file, and at most the
116
+ * last {@link DIAGNOSTICS_TAIL_BYTES}. The path comes from our own child, but
117
+ * the market only ever needs the end of a log, and reading an arbitrary
118
+ * amount of an arbitrary file is not worth anything it could add.
119
+ *
120
+ * @returns the path and the text, or null when the output names no readable
121
+ * diagnostics file.
122
+ */
123
+ export declare function diagnosticsTail(result: {
124
+ stdout: string;
125
+ stderr: string;
126
+ }): {
127
+ path: string;
128
+ text: string;
129
+ } | null;
105
130
  /**
106
131
  * Whether pnpm never started at all, so the profile cannot have been touched.
107
132
  *
@@ -164,7 +189,7 @@ export declare function retargetCollections(run: PluginRunner, profile: string,
164
189
  * profile at all, which is a broken plugin-command channel rather than
165
190
  * anything wrong with the plugin (#258).
166
191
  */
167
- export declare function validateAddedPlugins(run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string): Promise<{
192
+ export declare function validateAddedPlugins(run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string, hostDirectory?: string | null): Promise<{
168
193
  added: string[];
169
194
  keep: string[];
170
195
  removedBroken: string[];
@@ -174,6 +199,48 @@ export declare function validateAddedPlugins(run: PluginRunner, profile: string,
174
199
  owner: string;
175
200
  }[];
176
201
  }>;
202
+ /**
203
+ * The node_modules root of the DSH host deployment `directory` belongs to.
204
+ *
205
+ * CLI layouts install the host as `<prefix>/node_modules/@deepseek-ai/dsh`,
206
+ * so the shared root is two dirname steps up; a flat Desktop layout keeps
207
+ * the host package at the deployment root (#662's
208
+ * `<desktop-app>\dependencies\dsh`), with its pnpm-managed node_modules
209
+ * directly beside its package.json. `dshHostInfo()` already distinguishes
210
+ * the two — this is pure path arithmetic on whichever directory it returned.
211
+ */
212
+ export declare function hostNodeModulesRoot(directory: string): string;
213
+ /**
214
+ * Normalize a link target for path comparison: restore the UNC device form
215
+ * (`\\?\UNC\server\share` back to `\\server\share` — stripped of its prefix
216
+ * it is no longer absolute and resolve() would re-root it against the
217
+ * cwd), then remove the NT device prefixes `\\?\` and the subst-style
218
+ * `\??\` mklink stores. Measured on Node 24/win32: readlinkSync returns
219
+ * the plain absolute path, so these branches only matter for links created
220
+ * outside Node — but a comparison must not silently miss because of them.
221
+ */
222
+ export declare function normalizedLinkTarget(target: string): string;
223
+ /**
224
+ * Remove the host-side bridge link a confirmed uninstall leaves dangling
225
+ * (#662). The official boot projects profile packages into the host
226
+ * deployment's node_modules as links (Junction or SymbolicLink — lstat
227
+ * reports both as symlinks) and never reclaims them, and `dsh plugin
228
+ * remove` knows nothing about them, so without this the link outlives the
229
+ * package it pointed at and every tool that lstats its way through
230
+ * node_modules (rg first among them) fails on it.
231
+ *
232
+ * The gate is deliberately total: only `<host node_modules>/<name>` is ever
233
+ * touched, only when that entry is a link whose normalized target is
234
+ * exactly this profile's copy of `name`, and only when that copy is really
235
+ * gone — a live bridge for a package that is still installed must survive.
236
+ * A null `hostDirectory` (no host locatable — a plain `dsh web` from a
237
+ * global install) is a documented no-op.
238
+ *
239
+ * @returns whether a dangling bridge was removed. Never throws: the removal
240
+ * this cleans up after already succeeded, and a cleanup failure must not
241
+ * fail the uninstall that triggered it.
242
+ */
243
+ export declare function removeDanglingHostBridge(name: string, profileDirectory: string, hostDirectory: string | null): boolean;
177
244
  /**
178
245
  * Group flat `{id, owner}` conflict hits by the installed plugin that owns
179
246
  * them. What the user has to decide is which PLUGINS to uninstall, not which
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
33
33
  */
34
34
  export declare const HOST_NAMESPACE_RE: RegExp;
35
35
  export interface PnpmFailure {
36
- code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key';
36
+ code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom';
37
37
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
38
38
  message: string;
39
39
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
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.59.0",
4
+ "version": "1.61.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/check.ts CHANGED
@@ -947,7 +947,19 @@ export function buildBundleLayers(
947
947
  // fatal verdict and rolled back a good update (#369) — while `dsh
948
948
  // --dump-config` on the same profile exited 0. Unknown has to read as
949
949
  // unknown; the profile's own bundles are still judged normally.
950
- if (hostProvided) {
950
+ //
951
+ // The same holds for any official bundle while the installation itself
952
+ // cannot be located (#676): a desktop build ships more in-box bundles
953
+ // than the three named in INBOX_BUNDLES —
954
+ // `@deepseek-ai/dsh-experimental-agent-team-profile` among them — and a
955
+ // fixed list, or an install-path shape, cannot know which. Two
956
+ // reporters saw that bundle called "not installed — will fail to boot"
957
+ // while its three entries were active in the running host. Only
958
+ // DeepSeek publishes under `@deepseek-ai/`, so while the installation
959
+ // is out of sight such a bundle is unknown, not missing. When the
960
+ // installation IS located and the bundle is in neither it nor the
961
+ // profile, the fatal verdict below still applies.
962
+ if (hostProvided || (dshInstallDir === null && name.startsWith('@deepseek-ai/'))) {
951
963
  layer.error = null
952
964
  layer.unresolvedInbox = true
953
965
  return layer
@@ -1490,6 +1490,8 @@ export function MarketSection(props: MarketSectionProps) {
1490
1490
  * real switch state so hand-edited cordis.patch.yml toggles are visible.
1491
1491
  */
1492
1492
  const [patchDisabledNames, setPatchDisabledNames] = useState<string[]>([])
1493
+ /** Bundle packages DSH's own plugin page turned off by leaving them out of dsh.profile.bundles (#696). */
1494
+ const [unbundledNames, setUnbundledNames] = useState<string[]>([])
1493
1495
  const [groups, setGroups] = useState<Record<string, string[]>>({})
1494
1496
  const [groupOrder, setGroupOrder] = useState<string[]>([])
1495
1497
  /** Installed-tab sub-view: flat list or groups (All-plugins was removed —
@@ -1669,6 +1671,7 @@ export function MarketSection(props: MarketSectionProps) {
1669
1671
  setNotes(body.notes as Record<string, string>)
1670
1672
  }
1671
1673
  if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled)
1674
+ if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled)
1672
1675
  if (body.groups && typeof body.groups === 'object') setGroups(body.groups)
1673
1676
  if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder)
1674
1677
  if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url: unknown): url is string => typeof url === 'string'))
@@ -1697,8 +1700,8 @@ export function MarketSection(props: MarketSectionProps) {
1697
1700
  const favoriteUrlSet = useMemo(() => new Set(favoriteUrls), [favoriteUrls])
1698
1701
  /** Effective switch state: market disable list ∪ user-patch-layer disables. */
1699
1702
  const effectiveDisabledSet = useMemo(
1700
- () => new Set([...disabledNames, ...patchDisabledNames]),
1701
- [disabledNames, patchDisabledNames],
1703
+ () => new Set([...disabledNames, ...patchDisabledNames, ...unbundledNames]),
1704
+ [disabledNames, patchDisabledNames, unbundledNames],
1702
1705
  )
1703
1706
 
1704
1707
  useEffect(() => {
package/src/install.ts CHANGED
@@ -5,9 +5,10 @@
5
5
  * parameter so tests can substitute a recording fake.
6
6
  */
7
7
 
8
- import { existsSync } from 'node:fs'
9
- import { join } from 'node:path'
8
+ import { closeSync, existsSync, lstatSync, openSync, readlinkSync, readSync, rmSync, statSync } from 'node:fs'
9
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
10
10
  import type { InstallResult, PluginRunner } from './dsh-cli.ts'
11
+ import { findDshInstallDir } from './dsh-install.ts'
11
12
  import { classifyPnpmFailure, HOST_NAMESPACE_RE, isTransientPnpmFailure } from './pnpm-compat.ts'
12
13
  import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles, dropUnparseableBuildKeys } from './profile.ts'
13
14
  import { logEvent } from './log.ts'
@@ -194,6 +195,7 @@ export async function withHoistRecovery(
194
195
  // construction: directories are only removed when their owning pid is
195
196
  // gone (the name carries it), so a live download is never touched.
196
197
  await cleanOrphanedStore(run, profile)
198
+ const diagnostics = diagnosticsTail(result)
197
199
  const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)
198
200
  if (failure !== null) {
199
201
  result = {
@@ -201,6 +203,12 @@ export async function withHoistRecovery(
201
203
  stderr: failure.replaceOutput === true ? failure.message : `${result.stderr}\n\n${failure.message}`,
202
204
  ...(failure.replaceOutput === true ? { stdout: '' } : {}),
203
205
  }
206
+ } else if (diagnostics !== null) {
207
+ // The dsh CLI redirects the whole pnpm run into a file and leaves one
208
+ // line on stderr: `dsh: pnpm failed; diagnostics: <path>` (its own
209
+ // literal message). Everything a user or a report needs is in that
210
+ // file, so show its tail rather than the one line (#672).
211
+ result = { ...result, stderr: `${result.stderr}\n\n--- dsh diagnostics (${diagnostics.path}) ---\n${diagnostics.text}` }
204
212
  } else if (result.pnpmError !== undefined && result.pnpmError !== '') {
205
213
  // Nothing matched, but pnpm DID say what went wrong — in its ndjson
206
214
  // stream, which never reaches stderr. Without this the user is shown
@@ -218,6 +226,51 @@ export async function withHoistRecovery(
218
226
  return result
219
227
  }
220
228
 
229
+ /**
230
+ * The tail of the diagnostics file the dsh CLI pointed at, when it pointed at
231
+ * one (#672).
232
+ *
233
+ * `dsh plugin` writes pnpm's entire output to a file and prints only
234
+ * `dsh: pnpm failed; diagnostics: <path>`. For a failure the market cannot
235
+ * classify, that line is all it has — the reasons people reported (#244,
236
+ * #192, #138) were all "the UI shows one unhelpful line" for causes that
237
+ * were written down somewhere the UI never looked.
238
+ *
239
+ * Bounded on purpose: absolute paths only, a regular file, and at most the
240
+ * last {@link DIAGNOSTICS_TAIL_BYTES}. The path comes from our own child, but
241
+ * the market only ever needs the end of a log, and reading an arbitrary
242
+ * amount of an arbitrary file is not worth anything it could add.
243
+ *
244
+ * @returns the path and the text, or null when the output names no readable
245
+ * diagnostics file.
246
+ */
247
+ export function diagnosticsTail(result: { stdout: string; stderr: string }): { path: string; text: string } | null {
248
+ const match = /(?:^|\n)\s*dsh: [^\n]*diagnostics:\s*(\S+)\s*$/m.exec(result.stderr)
249
+ ?? /(?:^|\n)\s*dsh: [^\n]*diagnostics:\s*(\S+)\s*$/m.exec(result.stdout)
250
+ if (match === null) return null
251
+ const path = match[1]!
252
+ if (!isAbsolute(path)) return null
253
+ try {
254
+ if (!statSync(path).isFile()) return null
255
+ const size = statSync(path).size
256
+ const start = Math.max(0, size - DIAGNOSTICS_TAIL_BYTES)
257
+ const handle = openSync(path, 'r')
258
+ try {
259
+ const buffer = Buffer.alloc(Math.min(DIAGNOSTICS_TAIL_BYTES, size))
260
+ readSync(handle, buffer, 0, buffer.length, start)
261
+ const text = buffer.toString('utf8').trim()
262
+ return text === '' ? null : { path, text }
263
+ } finally {
264
+ closeSync(handle)
265
+ }
266
+ } catch {
267
+ return null
268
+ }
269
+ }
270
+
271
+ /** How much of a diagnostics file is worth showing. */
272
+ const DIAGNOSTICS_TAIL_BYTES = 8192
273
+
221
274
  /**
222
275
  * Whether pnpm never started at all, so the profile cannot have been touched.
223
276
  *
@@ -330,7 +383,7 @@ export async function retargetCollections(
330
383
  * anything wrong with the plugin (#258).
331
384
  */
332
385
  export async function validateAddedPlugins(
333
- run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string,
386
+ run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string, hostDirectory?: string | null,
334
387
  ): Promise<{ added: string[]; keep: string[]; removedBroken: string[]; conflicts: { name: string; id: string; owner: string }[] }> {
335
388
  const dir = profileDir(profile, explicitDir)
336
389
  const addedNow = Object.keys(readInstalled(profile, dir)).filter(n => !before.has(n))
@@ -346,7 +399,7 @@ export async function validateAddedPlugins(
346
399
  // ship no entry of their own (#103) and must not be uninstalled here.
347
400
  if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
348
401
  removedBroken.push(n)
349
- await removeAndReconcile(run, profile, dir, n)
402
+ await removeAndReconcile(run, profile, dir, n, hostDirectory)
350
403
  continue
351
404
  }
352
405
  const clash = conflictingEntryIds(dir, n, existingBundles)
@@ -356,7 +409,7 @@ export async function validateAddedPlugins(
356
409
  removedBroken.push(n)
357
410
  logEvent('error', 'install',
358
411
  `${n}: loader entry id conflict with ${clash[0].owner} (${clash.map(hit => hit.id).join(', ')}) — removing, it would break the next boot`)
359
- await removeAndReconcile(run, profile, dir, n)
412
+ await removeAndReconcile(run, profile, dir, n, hostDirectory)
360
413
  continue
361
414
  }
362
415
  keep.push(n)
@@ -382,11 +435,14 @@ export async function validateAddedPlugins(
382
435
  * @param profile - the profile name for manifest writes.
383
436
  * @param dir - the profile directory the validation reads.
384
437
  * @param name - the package being removed.
438
+ * @param hostDirectory - the DSH host deployment directory whose node_modules
439
+ * may hold a bridge link for this package (#662); resolved when omitted.
385
440
  */
386
- async function removeAndReconcile(run: PluginRunner, profile: string, dir: string, name: string): Promise<void> {
441
+ async function removeAndReconcile(run: PluginRunner, profile: string, dir: string, name: string, hostDirectory: string | null = findDshInstallDir()): Promise<void> {
387
442
  const result = await run(profile, ['remove', name])
388
443
  const gone = !existsSync(join(dir, 'node_modules', name, 'package.json'))
389
444
  if (gone) {
445
+ removeDanglingHostBridge(name, dir, hostDirectory)
390
446
  if (dropFromManifest(profile, name, dir)) {
391
447
  logEvent('error', 'install',
392
448
  `${name}: remove ${result.exitCode === 0 ? 'skipped the manifest reconcile' : `failed (exit ${String(result.exitCode)})`} but the package is gone from disk — dropped its dependency/bundle rows so the next boot stays loadable`)
@@ -399,6 +455,99 @@ async function removeAndReconcile(run: PluginRunner, profile: string, dir: strin
399
455
  }
400
456
  }
401
457
 
458
+ /**
459
+ * The node_modules root of the DSH host deployment `directory` belongs to.
460
+ *
461
+ * CLI layouts install the host as `<prefix>/node_modules/@deepseek-ai/dsh`,
462
+ * so the shared root is two dirname steps up; a flat Desktop layout keeps
463
+ * the host package at the deployment root (#662's
464
+ * `<desktop-app>\dependencies\dsh`), with its pnpm-managed node_modules
465
+ * directly beside its package.json. `dshHostInfo()` already distinguishes
466
+ * the two — this is pure path arithmetic on whichever directory it returned.
467
+ */
468
+ export function hostNodeModulesRoot(directory: string): string {
469
+ const segments = directory.split(/[/\\]+/).filter(segment => segment !== '')
470
+ const n = segments.length
471
+ if (n >= 3 && segments[n - 3].toLowerCase() === 'node_modules' && segments[n - 2].toLowerCase() === '@deepseek-ai') {
472
+ return resolve(dirname(dirname(directory)))
473
+ }
474
+ return resolve(directory, 'node_modules')
475
+ }
476
+
477
+ /**
478
+ * Normalize a link target for path comparison: restore the UNC device form
479
+ * (`\\?\UNC\server\share` back to `\\server\share` — stripped of its prefix
480
+ * it is no longer absolute and resolve() would re-root it against the
481
+ * cwd), then remove the NT device prefixes `\\?\` and the subst-style
482
+ * `\??\` mklink stores. Measured on Node 24/win32: readlinkSync returns
483
+ * the plain absolute path, so these branches only matter for links created
484
+ * outside Node — but a comparison must not silently miss because of them.
485
+ */
486
+ export function normalizedLinkTarget(target: string): string {
487
+ return target
488
+ .replace(/^\\\\\?\\UNC\\/, '\\\\')
489
+ .replace(/^(?:\\\\\?\\|\\\?\?\\)/, '')
490
+ }
491
+
492
+ /**
493
+ * Whether a link target names the profile's copy of a package. Junction
494
+ * targets keep the case they were created with, so the comparison is
495
+ * case-insensitive on win32, where the filesystem itself is.
496
+ */
497
+ function pointsAtProfilePackage(target: string, expected: string): boolean {
498
+ const left = resolve(normalizedLinkTarget(target))
499
+ const right = resolve(expected)
500
+ return process.platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right
501
+ }
502
+
503
+ /**
504
+ * Remove the host-side bridge link a confirmed uninstall leaves dangling
505
+ * (#662). The official boot projects profile packages into the host
506
+ * deployment's node_modules as links (Junction or SymbolicLink — lstat
507
+ * reports both as symlinks) and never reclaims them, and `dsh plugin
508
+ * remove` knows nothing about them, so without this the link outlives the
509
+ * package it pointed at and every tool that lstats its way through
510
+ * node_modules (rg first among them) fails on it.
511
+ *
512
+ * The gate is deliberately total: only `<host node_modules>/<name>` is ever
513
+ * touched, only when that entry is a link whose normalized target is
514
+ * exactly this profile's copy of `name`, and only when that copy is really
515
+ * gone — a live bridge for a package that is still installed must survive.
516
+ * A null `hostDirectory` (no host locatable — a plain `dsh web` from a
517
+ * global install) is a documented no-op.
518
+ *
519
+ * @returns whether a dangling bridge was removed. Never throws: the removal
520
+ * this cleans up after already succeeded, and a cleanup failure must not
521
+ * fail the uninstall that triggered it.
522
+ */
523
+ export function removeDanglingHostBridge(name: string, profileDirectory: string, hostDirectory: string | null): boolean {
524
+ if (hostDirectory === null) return false
525
+ const bridge = join(hostNodeModulesRoot(hostDirectory), name)
526
+ const unlinked = join(profileDirectory, 'node_modules', name)
527
+ try {
528
+ if (!lstatSync(bridge).isSymbolicLink()) return false
529
+ // The gone-check mirrors removeAndReconcile's manifest truth: a package
530
+ // whose package.json is gone is uninstalled even if an empty directory
531
+ // lingered behind, and its bridge is exactly the dangling link #662 is
532
+ // about.
533
+ if (!pointsAtProfilePackage(readlinkSync(bridge), unlinked) || existsSync(join(unlinked, 'package.json'))) return false
534
+ // No `recursive`: rmSync on a link unlinks the link itself only
535
+ // (measured on win32/Node 24 for live and dangling junctions and dir
536
+ // symlinks), and leaving it off keeps a race that swaps the link for a
537
+ // real directory from ever deleting that directory's tree.
538
+ rmSync(bridge, { force: true })
539
+ } catch (error) {
540
+ // ENOENT is the ordinary "no bridge there" answer (and a lost race
541
+ // while unlinking); anything else is worth a line in the log.
542
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
543
+ logEvent('warn', 'uninstall', `${name}: host bridge cleanup skipped — ${error instanceof Error ? error.message : String(error)}`)
544
+ }
545
+ return false
546
+ }
547
+ logEvent('info', 'uninstall', `${name}: removed the dangling host bridge the boot projection left at ${bridge}`)
548
+ return true
549
+ }
550
+
402
551
  /**
403
552
  * Group flat `{id, owner}` conflict hits by the installed plugin that owns
404
553
  * them. What the user has to decide is which PLUGINS to uninstall, not which
@@ -48,7 +48,7 @@ export interface PnpmFailure {
48
48
  | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch'
49
49
  | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout'
50
50
  | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked'
51
- | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key'
51
+ | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom'
52
52
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
53
53
  message: string
54
54
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
@@ -350,6 +350,21 @@ export function classifyPnpmFailure(output: string, exitCode?: number | null): P
350
350
  // pnpm's FETCHER, before anything lands in node_modules — so the package
351
351
  // the user must approve is not installed yet, and pnpm's own hint names a
352
352
  // commit-pinned codeload URL that changes on every push.
353
+ // #701: pnpm 12's native engine (pnpm-native) aborting on an allocation it
354
+ // cannot get — "memory allocation of 5368709120 bytes failed", Windows exit
355
+ // 3221226505 (0xC0000409, how a Rust abort ends there). Reported with an
356
+ // A/B: on pnpm 12.5.1 every run with `autoInstallPeers: false` in the
357
+ // workspace file (DSH writes it into every profile) aborted at ~5 GB peak
358
+ // RSS, every run without it passed at ~80 MB, with free memory to spare.
359
+ // Nothing about the plugin being installed; not something a retry of the
360
+ // same command changes on the reporter's data, so none is attempted.
361
+ if (/memory allocation of \d+ bytes failed/.test(output) || exitCode === 3221226505) {
362
+ return {
363
+ code: 'native-oom',
364
+ recoverable: false,
365
+ message: 'pnpm 12 的原生引擎在处理这个 profile 时耗尽了内存并中止——和要安装的插件无关。在 pnpm 修复之前,请改用 pnpm 11(npm install -g pnpm@11)后再试。 / pnpm 12\'s native engine ran out of memory on this profile and aborted — the plugin being installed is not the cause. Until pnpm fixes it, switch to pnpm 11 (npm install -g pnpm@11) and try again.',
366
+ }
367
+ }
353
368
  // #698: pnpm 10.26+ and 11.0–11.5 read an allowBuilds key as
354
369
  // `name@<version union>`, so a git or archive source there fails the WHOLE
355
370
  // workspace file — every pnpm command in the profile, not the one plugin.
package/src/routes.ts CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  mountClientOnlyDeps, purgeMarketState, readMarketState, writeMarketState,
20
20
  } from './hot.ts'
21
21
  import { createGroup, deleteGroup, removeFromGroups, renameGroup, setGroupMembers } from './groups.ts'
22
- import { dshHostInfo } from './dsh-install.ts'
22
+ import { dshHostInfo, findDshInstallDir } from './dsh-install.ts'
23
23
  import { deriveHostCompatibility, DiscoveryManifestIndex } from './discovery-compatibility.ts'
24
24
  import { configurePersistentLog, exportLogs, logEvent, readPersistentLog } from './log.ts'
25
25
  import { marketFetch } from './net.ts'
@@ -38,7 +38,7 @@ import { applyPreset, deletePreset, listPresets, previewPreset, savePreset } fro
38
38
  import { createProfileSnapshot, DEFAULT_MAX_SNAPSHOTS, deleteSnapshot, listSnapshots, restoreSnapshot } from './snapshot.ts'
39
39
  import { trialValidate } from './trial.ts'
40
40
  import { codeloadAllowBuildsKey, findCatalogEntryForLocal, findInstalledAlias, gitCommitOfTarget, githubCommitOfTarget, githubTargetAtCommit, gitAllowBuildsKey, gitRefOfTarget, gitTargetAtCommit, gitUpdateTarget, hostedRepoKey, pinnedGitAllowBuildsKey, installTargetFor, isGenerationLink, isLocalSpec, NPM_NAME_RE, repoOfTarget, restoreBlockedByWorkspace, restoreTargetForLocal, workspaceProtocolDeps } from './sources.ts'
41
- import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
41
+ import { failureDetail, groupConflictsByOwner, isStaleUpdate, parseIgnoredBuilds, parsePrepareKey, parsePrepareNotAllowed, pnpmBlockedByOpenFiles, pnpmNeverStarted, RELEASE_AGE_OVERRIDE, removeDanglingHostBridge, retargetCollections, validateAddedPlugins, withHoistRecovery } from './install.ts'
42
42
  import { classifyPnpmFailure } from './pnpm-compat.ts'
43
43
  import { asChannel, CHANNELS, DIST_TAG, resolveChannel, type Channel } from './channels.ts'
44
44
  import {
@@ -244,6 +244,16 @@ function packageHasClientPart(profileDirectory: string, name: string): boolean {
244
244
  * the package, and the key written is the one pnpm printed, never text from
245
245
  * the request.
246
246
  */
247
+ /** Whether an installed package's manifest declares a bundle. */
248
+ function declaresBundle(profileDirectory: string, name: string): boolean {
249
+ try {
250
+ const manifest = JSON.parse(readFileSync(join(profileDirectory, 'node_modules', name, 'package.json'), 'utf8')) as { dsh?: { bundle?: unknown } }
251
+ return manifest.dsh?.bundle !== undefined
252
+ } catch {
253
+ return false
254
+ }
255
+ }
256
+
247
257
  const prepareRefusals = new Map<string, string | null>()
248
258
 
249
259
  function blockedBuilds(result: { ignoredBuilds?: unknown; stdout: string; stderr: string }): string[] | undefined {
@@ -435,6 +445,34 @@ export function mountMarketRoutes(
435
445
  setCustomGithubProxy(fresh.githubProxy ?? null)
436
446
  }
437
447
 
448
+ /**
449
+ * Follow an enable made OUTSIDE the market (#696).
450
+ *
451
+ * The market keeps its own disable list in state.json, and the self-heal
452
+ * guard below and the boot replay put any plugin on that list back down.
453
+ * DSH's own Settings → Plugins page enables a row by flipping it to
454
+ * `disabled: false` in the shared patch layer — it has never heard of
455
+ * state.json — so its enable was silently undone within a second and again
456
+ * on every boot, and the official switch looked broken.
457
+ *
458
+ * The patch layer is the one truth both managers share. A row explicitly
459
+ * enabled there while no row of the same package is disabled can only be a
460
+ * newer decision than the market's list (the market removes a name from
461
+ * its list BEFORE writing its own enable row), so the list follows it.
462
+ *
463
+ * @returns true when the package was re-enabled elsewhere and has been
464
+ * dropped from the market's disable list.
465
+ */
466
+ function followOutsideEnable(name: string): boolean {
467
+ const patch = readUserPatchState(userPatchPath)
468
+ const flags = packagePatchFlags(host, activeProfileDir, [name], patch)
469
+ if (!flags.forced.includes(name) || flags.disabled.includes(name)) return false
470
+ disabled.delete(name)
471
+ writeMarketState(activeProfileDir, { disabled, groups, groupOrder })
472
+ logEvent('info', 'toggle', `${name}: its rows were re-enabled outside the market (#696) — following that instead of switching it back off`)
473
+ return true
474
+ }
475
+
438
476
  // Client-only packages (dsh.client without dsh.bundle) are invisible to the
439
477
  // bundle layer in every boot; the market shim-mounts them so their client
440
478
  // bundles are actually served.
@@ -444,7 +482,8 @@ export function mountMarketRoutes(
444
482
  // switched away from get live-disabled again (bundle trees are
445
483
  // in-memory, so the disable never persists on its own). Client-only
446
484
  // shims for disabled plugins were already skipped by mountClientOnlyDeps.
447
- for (const name of disabled) {
485
+ for (const name of [...disabled]) {
486
+ if (followOutsideEnable(name)) continue
448
487
  if (await themes.setEntryDisabled(name, true)) logEvent('info', 'boot', `plugin kept off: ${name}`)
449
488
  }
450
489
  })
@@ -454,7 +493,7 @@ export function mountMarketRoutes(
454
493
  // up for a plugin the user switched off, put it back down.
455
494
  host.on?.('internal/plugin', (fiber) => {
456
495
  const name = fiber.entry?.options?.name
457
- if (name !== undefined && disabled.has(name)) void themes.setEntryDisabled(name, true)
496
+ if (name !== undefined && disabled.has(name) && !followOutsideEnable(name)) void themes.setEntryDisabled(name, true)
458
497
  })
459
498
  let installing = false
460
499
  let restarting = false
@@ -1055,6 +1094,9 @@ export function mountMarketRoutes(
1055
1094
  // with two activation sources must not have the second one skipped
1056
1095
  // because the first succeeded.
1057
1096
  const unmounted = await hotUnmount(name)
1097
+ // #662: the removal is confirmed — drop the host bridge link the boot
1098
+ // projection may have left pointing at the now-gone package.
1099
+ removeDanglingHostBridge(name, activeProfileDir, config.dshInstallDir ?? findDshInstallDir())
1058
1100
  const entryDisabled = await themes.setEntryDisabled(name, true)
1059
1101
  const hot = (unmounted || entryDisabled) && !native
1060
1102
  if (native) {
@@ -1948,10 +1990,21 @@ export function mountMarketRoutes(
1948
1990
  return typeof manifest === 'object' && manifest !== null
1949
1991
  && (manifest as { dsh?: unknown }).dsh !== undefined
1950
1992
  }
1993
+ // A package that declares a bundle but is not in dsh.profile.bundles
1994
+ // is not composed at boot: DSH's own plugin page turns a package off
1995
+ // by removing it from that list, and nothing the market recorded
1996
+ // says so (#696). Reported as off, unless it is live right now — a
1997
+ // hot mount from this session still runs until the next restart.
1998
+ const composedBundles = new Set(readProfileBundles(activeProfileDir))
1999
+ const unbundled = Object.keys(installed).filter(name => {
2000
+ if (INBOX_BUNDLES.has(name) || composedBundles.has(name) || live.has(name)) return false
2001
+ const manifest = installedManifests.get(name) as { dsh?: { bundle?: unknown } } | null | undefined
2002
+ return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined
2003
+ })
1951
2004
  for (const name of Object.keys(installed)) {
1952
2005
  const result = activationAfterReplace(
1953
2006
  verifyActivation(config.profile, name, live, activeProfileDir,
1954
- disabled.has(name) || patchFlags.disabled.includes(name)),
2007
+ disabled.has(name) || patchFlags.disabled.includes(name) || unbundled.includes(name)),
1955
2008
  replacedWhileLive.has(name),
1956
2009
  )
1957
2010
  // A package with no dsh surface of its own, outside the bundle
@@ -1989,6 +2042,7 @@ export function mountMarketRoutes(
1989
2042
  favorites: readMarketState(activeProfileDir).favorites ?? [],
1990
2043
  patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts },
1991
2044
  patchDisabled: patchFlags.disabled,
2045
+ unbundled,
1992
2046
  patchForced: patchFlags.forced,
1993
2047
  bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
1994
2048
  })
@@ -2399,13 +2453,23 @@ export function mountMarketRoutes(
2399
2453
  // disabled) is NOT dropped: #147 requires disabling it to leave the
2400
2454
  // neighbour live, and the e2e fixture-cross re-enable breaks otherwise.
2401
2455
  const disablesOthers = carrierDisableIds(activeProfileDir, name)
2456
+ // Turned off by DSH's own plugin page, which removes a package from
2457
+ // dsh.profile.bundles (#696): enabling it here has to put it back,
2458
+ // or the patch rows flip, the switch reads on, and nothing composes
2459
+ // it on the next boot. Unlike a carrier this does NOT force a
2460
+ // restart: the enable below still brings it up in this process, and
2461
+ // whether a restart is needed is whether it is live afterwards.
2462
+ const reBundle = enabled && disablesOthers.length === 0 && !INBOX_BUNDLES.has(name)
2463
+ && declaresBundle(activeProfileDir, name) && !readProfileBundles(activeProfileDir).includes(name)
2402
2464
  const isCarrier = disablesOthers.length > 0
2403
2465
  let bundleSwitch: { ok: boolean; reason: string | null } = { ok: true, reason: null }
2404
- if (isCarrier) {
2466
+ if (isCarrier || reBundle) {
2405
2467
  try {
2406
2468
  if (enabled) addProfileBundle(activeProfileDir, name)
2407
2469
  else removeProfileBundle(activeProfileDir, name)
2408
- logEvent('info', 'toggle', `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`)
2470
+ logEvent('info', 'toggle', reBundle
2471
+ ? `${name}: re-added to dsh.profile.bundles, which another manager had removed it from (#696)`
2472
+ : `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`)
2409
2473
  } catch (error) {
2410
2474
  bundleSwitch = { ok: false, reason: error instanceof Error ? error.message : String(error) }
2411
2475
  logEvent('warn', 'toggle', `${name}: carrier bundle switch failed — ${bundleSwitch.reason}`)
@@ -4097,6 +4161,10 @@ sendJson(response, 200, { updates })
4097
4161
  // has just been removed. Only rows belonging to packages on the
4098
4162
  // market's own disable list are touched — a hand-written row is
4099
4163
  // the user's, not ours.
4164
+ // #662 first, while the filesystem is the only thing touched:
4165
+ // the market's own host bridge dangles the moment the remove
4166
+ // succeeds, and this route is the last code of ours to run.
4167
+ removeDanglingHostBridge(selfName, activeProfileDir, config.dshInstallDir ?? findDshInstallDir())
4100
4168
  const purge = body.purge === true
4101
4169
  const restored: string[] = []
4102
4170
  if (purge) {
@@ -4386,6 +4454,13 @@ sendJson(response, 200, { updates })
4386
4454
  sendJson(response, 400, { error: 'the market cannot uninstall itself; use the dsh CLI' })
4387
4455
  return
4388
4456
  }
4457
+ // The bridge cleanup this route performs joins the package name
4458
+ // into a host node_modules path (#662); a hand-edited manifest
4459
+ // carrying `../../evil` must not escape that join.
4460
+ if (!NPM_NAME_RE.test(name)) {
4461
+ sendJson(response, 400, { error: 'plugin is not installed' })
4462
+ return
4463
+ }
4389
4464
  if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
4390
4465
  sendJson(response, 400, { error: 'plugin is not installed' })
4391
4466
  return
@@ -4471,6 +4546,10 @@ sendJson(response, 200, { updates })
4471
4546
  let hot = false
4472
4547
  if (ok || halfGone) {
4473
4548
  invalidateUpdates()
4549
+ // #662: the removal is final (confirmed exit or reconciled
4550
+ // from disk truth) — the host node_modules bridge the boot
4551
+ // projection left for this package must not outlive it.
4552
+ removeDanglingHostBridge(name, activeProfileDir, config.dshInstallDir ?? findDshInstallDir())
4474
4553
  hot = await hotUnmount(name)
4475
4554
  // Bundle-layer plugins never hot-mount, but their loader entry
4476
4555
  // is still LIVE in this process — after the remove deleted the
@@ -4792,6 +4871,12 @@ sendJson(response, 200, { updates })
4792
4871
  // keep their partial state on purpose (the user sees the diff
4793
4872
  // and decides).
4794
4873
  const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir)
4874
+ // The lockfile too (#701): pnpm writes it before it links, so a
4875
+ // run that dies in between — a native crash, a kill — leaves a
4876
+ // lock that names a package the manifest never got. The update
4877
+ // route has always restored both; a fresh install restored only
4878
+ // the manifest and left the half for the next pnpm run to trip on.
4879
+ const lockfileBefore = captureProfileLockfile()
4795
4880
  const pinned = target === pinnedTarget && pinnedTarget !== plainTarget
4796
4881
  let result = await (pinned ? runPluginKeepingReleaseAge : runPlugin)(config.profile, ['add', target])
4797
4882
  // Two ways a pinned add can fail that a bare add would not, and
@@ -4825,6 +4910,10 @@ sendJson(response, 200, { updates })
4825
4910
  if ((result.exitCode !== 0 || result.timedOut) && !cancelled) {
4826
4911
  const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
4827
4912
  if (rolledBack.length > 0) logEvent('warn', 'install', `${target}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`)
4913
+ if (lockfileBefore.ok) {
4914
+ const lock = restoreProfileLockfile(lockfileBefore.snapshot)
4915
+ if (!lock.ok) logEvent('warn', 'install', `${target}: could not restore pnpm-lock.yaml after the failed run: ${lock.detail ?? 'unknown'}`)
4916
+ }
4828
4917
  }
4829
4918
  let ok = result.exitCode === 0 && !result.timedOut && !cancelled
4830
4919
  const cancelDiff = cancelled ? changedSince(beforeSpecs) : null