dshmarket 1.60.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/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
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';
@@ -966,6 +966,9 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
966
966
  // with two activation sources must not have the second one skipped
967
967
  // because the first succeeded.
968
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());
969
972
  const entryDisabled = await themes.setEntryDisabled(name, true);
970
973
  const hot = (unmounted || entryDisabled) && !native;
971
974
  if (native) {
@@ -3998,6 +4001,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
3998
4001
  // has just been removed. Only rows belonging to packages on the
3999
4002
  // market's own disable list are touched — a hand-written row is
4000
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());
4001
4008
  const purge = body.purge === true;
4002
4009
  const restored = [];
4003
4010
  if (purge) {
@@ -4287,6 +4294,13 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
4287
4294
  sendJson(response, 400, { error: 'the market cannot uninstall itself; use the dsh CLI' });
4288
4295
  return;
4289
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
+ }
4290
4304
  if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
4291
4305
  sendJson(response, 400, { error: 'plugin is not installed' });
4292
4306
  return;
@@ -4372,6 +4386,10 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
4372
4386
  let hot = false;
4373
4387
  if (ok || halfGone) {
4374
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());
4375
4393
  hot = await hotUnmount(name);
4376
4394
  // Bundle-layer plugins never hot-mount, but their loader entry
4377
4395
  // is still LIVE in this process — after the remove deleted the
@@ -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
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.60.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/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
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 {
@@ -1094,6 +1094,9 @@ export function mountMarketRoutes(
1094
1094
  // with two activation sources must not have the second one skipped
1095
1095
  // because the first succeeded.
1096
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())
1097
1100
  const entryDisabled = await themes.setEntryDisabled(name, true)
1098
1101
  const hot = (unmounted || entryDisabled) && !native
1099
1102
  if (native) {
@@ -4158,6 +4161,10 @@ sendJson(response, 200, { updates })
4158
4161
  // has just been removed. Only rows belonging to packages on the
4159
4162
  // market's own disable list are touched — a hand-written row is
4160
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())
4161
4168
  const purge = body.purge === true
4162
4169
  const restored: string[] = []
4163
4170
  if (purge) {
@@ -4447,6 +4454,13 @@ sendJson(response, 200, { updates })
4447
4454
  sendJson(response, 400, { error: 'the market cannot uninstall itself; use the dsh CLI' })
4448
4455
  return
4449
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
+ }
4450
4464
  if (readInstalled(config.profile, activeProfileDir)[name] === undefined) {
4451
4465
  sendJson(response, 400, { error: 'plugin is not installed' })
4452
4466
  return
@@ -4532,6 +4546,10 @@ sendJson(response, 200, { updates })
4532
4546
  let hot = false
4533
4547
  if (ok || halfGone) {
4534
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())
4535
4553
  hot = await hotUnmount(name)
4536
4554
  // Bundle-layer plugins never hot-mount, but their loader entry
4537
4555
  // is still LIVE in this process — after the remove deleted the