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 +12 -1
- package/lib/check.js +13 -1
- package/lib/install.js +159 -6
- package/lib/pnpm-compat.js +15 -0
- package/lib/routes.js +99 -7
- package/lib/types/install.d.ts +68 -1
- package/lib/types/pnpm-compat.d.ts +1 -1
- package/package.json +1 -1
- package/src/check.ts +13 -1
- package/src/client/MarketSection.tsx +5 -2
- package/src/install.ts +155 -6
- package/src/pnpm-compat.ts +16 -1
- package/src/routes.ts +96 -7
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([
|
|
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
|
-
|
|
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
|
package/lib/pnpm-compat.js
CHANGED
|
@@ -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',
|
|
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;
|
package/lib/types/install.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
package/src/pnpm-compat.ts
CHANGED
|
@@ -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',
|
|
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
|