dshmarket 1.59.0 → 1.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/client.js CHANGED
@@ -7139,6 +7139,8 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7139
7139
  * real switch state so hand-edited cordis.patch.yml toggles are visible.
7140
7140
  */
7141
7141
  const [patchDisabledNames, setPatchDisabledNames] = (0, react.useState)([]);
7142
+ /** Bundle packages DSH's own plugin page turned off by leaving them out of dsh.profile.bundles (#696). */
7143
+ const [unbundledNames, setUnbundledNames] = (0, react.useState)([]);
7142
7144
  const [groups, setGroups] = (0, react.useState)({});
7143
7145
  const [groupOrder, setGroupOrder] = (0, react.useState)([]);
7144
7146
  /** Installed-tab sub-view: flat list or groups (All-plugins was removed —
@@ -7309,6 +7311,7 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7309
7311
  }
7310
7312
  if (body.notes !== null && typeof body.notes === "object" && !Array.isArray(body.notes)) setNotes(body.notes);
7311
7313
  if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled);
7314
+ if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled);
7312
7315
  if (body.groups && typeof body.groups === "object") setGroups(body.groups);
7313
7316
  if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder);
7314
7317
  if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url) => typeof url === "string"));
@@ -7324,7 +7327,15 @@ window.__ModuleLoader__.load({ id: "dshmarket", factory: (require) => {
7324
7327
  (0, react.useMemo)(() => new Set(disabledNames), [disabledNames]);
7325
7328
  const favoriteUrlSet = (0, react.useMemo)(() => new Set(favoriteUrls), [favoriteUrls]);
7326
7329
  /** Effective switch state: market disable list ∪ user-patch-layer disables. */
7327
- const effectiveDisabledSet = (0, react.useMemo)(() => /* @__PURE__ */ new Set([...disabledNames, ...patchDisabledNames]), [disabledNames, patchDisabledNames]);
7330
+ const effectiveDisabledSet = (0, react.useMemo)(() => /* @__PURE__ */ new Set([
7331
+ ...disabledNames,
7332
+ ...patchDisabledNames,
7333
+ ...unbundledNames
7334
+ ]), [
7335
+ disabledNames,
7336
+ patchDisabledNames,
7337
+ unbundledNames
7338
+ ]);
7328
7339
  (0, react.useEffect)(() => {
7329
7340
  if (tab !== "themes" && themesFullscreen) setThemesFullscreen(false);
7330
7341
  }, [tab, themesFullscreen]);
package/lib/check.js CHANGED
@@ -759,7 +759,19 @@ export function buildBundleLayers(profileDirectory, bundleNames, specs, dshInsta
759
759
  // fatal verdict and rolled back a good update (#369) — while `dsh
760
760
  // --dump-config` on the same profile exited 0. Unknown has to read as
761
761
  // unknown; the profile's own bundles are still judged normally.
762
- if (hostProvided) {
762
+ //
763
+ // The same holds for any official bundle while the installation itself
764
+ // cannot be located (#676): a desktop build ships more in-box bundles
765
+ // than the three named in INBOX_BUNDLES —
766
+ // `@deepseek-ai/dsh-experimental-agent-team-profile` among them — and a
767
+ // fixed list, or an install-path shape, cannot know which. Two
768
+ // reporters saw that bundle called "not installed — will fail to boot"
769
+ // while its three entries were active in the running host. Only
770
+ // DeepSeek publishes under `@deepseek-ai/`, so while the installation
771
+ // is out of sight such a bundle is unknown, not missing. When the
772
+ // installation IS located and the bundle is in neither it nor the
773
+ // profile, the fatal verdict below still applies.
774
+ if (hostProvided || (dshInstallDir === null && name.startsWith('@deepseek-ai/'))) {
763
775
  layer.error = null;
764
776
  layer.unresolvedInbox = true;
765
777
  return layer;
@@ -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
@@ -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;
@@ -1806,8 +1846,20 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1806
1846
  return typeof manifest === 'object' && manifest !== null
1807
1847
  && manifest.dsh !== undefined;
1808
1848
  };
1849
+ // A package that declares a bundle but is not in dsh.profile.bundles
1850
+ // is not composed at boot: DSH's own plugin page turns a package off
1851
+ // by removing it from that list, and nothing the market recorded
1852
+ // says so (#696). Reported as off, unless it is live right now — a
1853
+ // hot mount from this session still runs until the next restart.
1854
+ const composedBundles = new Set(readProfileBundles(activeProfileDir));
1855
+ const unbundled = Object.keys(installed).filter(name => {
1856
+ if (INBOX_BUNDLES.has(name) || composedBundles.has(name) || live.has(name))
1857
+ return false;
1858
+ const manifest = installedManifests.get(name);
1859
+ return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined;
1860
+ });
1809
1861
  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));
1862
+ const result = activationAfterReplace(verifyActivation(config.profile, name, live, activeProfileDir, disabled.has(name) || patchFlags.disabled.includes(name) || unbundled.includes(name)), replacedWhileLive.has(name));
1811
1863
  // A package with no dsh surface of its own, outside the bundle
1812
1864
  // layer, that another installed package declares, is that package's
1813
1865
  // library rather than a plugin that failed to start (#634). Saying
@@ -1843,6 +1895,7 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
1843
1895
  favorites: readMarketState(activeProfileDir).favorites ?? [],
1844
1896
  patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts },
1845
1897
  patchDisabled: patchFlags.disabled,
1898
+ unbundled,
1846
1899
  patchForced: patchFlags.forced,
1847
1900
  bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
1848
1901
  });
@@ -2257,15 +2310,25 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
2257
2310
  // disabled) is NOT dropped: #147 requires disabling it to leave the
2258
2311
  // neighbour live, and the e2e fixture-cross re-enable breaks otherwise.
2259
2312
  const disablesOthers = carrierDisableIds(activeProfileDir, name);
2313
+ // Turned off by DSH's own plugin page, which removes a package from
2314
+ // dsh.profile.bundles (#696): enabling it here has to put it back,
2315
+ // or the patch rows flip, the switch reads on, and nothing composes
2316
+ // it on the next boot. Unlike a carrier this does NOT force a
2317
+ // restart: the enable below still brings it up in this process, and
2318
+ // whether a restart is needed is whether it is live afterwards.
2319
+ const reBundle = enabled && disablesOthers.length === 0 && !INBOX_BUNDLES.has(name)
2320
+ && declaresBundle(activeProfileDir, name) && !readProfileBundles(activeProfileDir).includes(name);
2260
2321
  const isCarrier = disablesOthers.length > 0;
2261
2322
  let bundleSwitch = { ok: true, reason: null };
2262
- if (isCarrier) {
2323
+ if (isCarrier || reBundle) {
2263
2324
  try {
2264
2325
  if (enabled)
2265
2326
  addProfileBundle(activeProfileDir, name);
2266
2327
  else
2267
2328
  removeProfileBundle(activeProfileDir, name);
2268
- logEvent('info', 'toggle', `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`);
2329
+ logEvent('info', 'toggle', reBundle
2330
+ ? `${name}: re-added to dsh.profile.bundles, which another manager had removed it from (#696)`
2331
+ : `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`);
2269
2332
  }
2270
2333
  catch (error) {
2271
2334
  bundleSwitch = { ok: false, reason: error instanceof Error ? error.message : String(error) };
@@ -4632,6 +4695,12 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
4632
4695
  // keep their partial state on purpose (the user sees the diff
4633
4696
  // and decides).
4634
4697
  const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir);
4698
+ // The lockfile too (#701): pnpm writes it before it links, so a
4699
+ // run that dies in between — a native crash, a kill — leaves a
4700
+ // lock that names a package the manifest never got. The update
4701
+ // route has always restored both; a fresh install restored only
4702
+ // the manifest and left the half for the next pnpm run to trip on.
4703
+ const lockfileBefore = captureProfileLockfile();
4635
4704
  const pinned = target === pinnedTarget && pinnedTarget !== plainTarget;
4636
4705
  let result = await (pinned ? runPluginKeepingReleaseAge : runPlugin)(config.profile, ['add', target]);
4637
4706
  // Two ways a pinned add can fail that a bare add would not, and
@@ -4666,6 +4735,11 @@ export function mountMarketRoutes(host, config, commandRuntime, agentsLookup) {
4666
4735
  const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir);
4667
4736
  if (rolledBack.length > 0)
4668
4737
  logEvent('warn', 'install', `${target}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`);
4738
+ if (lockfileBefore.ok) {
4739
+ const lock = restoreProfileLockfile(lockfileBefore.snapshot);
4740
+ if (!lock.ok)
4741
+ logEvent('warn', 'install', `${target}: could not restore pnpm-lock.yaml after the failed run: ${lock.detail ?? 'unknown'}`);
4742
+ }
4669
4743
  }
4670
4744
  let ok = result.exitCode === 0 && !result.timedOut && !cancelled;
4671
4745
  const cancelDiff = cancelled ? changedSince(beforeSpecs) : null;
@@ -33,7 +33,7 @@ export declare function pluginArgsFor(profileDir: string, pluginArgs: string[]):
33
33
  */
34
34
  export declare const HOST_NAMESPACE_RE: RegExp;
35
35
  export interface PnpmFailure {
36
- code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key';
36
+ code: 'adding-to-root' | 'not-a-workspace' | 'hoist-pattern-diff' | 'pnpm-missing' | 'release-age-violation' | 'ignored-builds' | 'git-prepare-not-allowed' | 'git-prepare-failed' | 'tarball-url-mismatch' | 'fetch-404' | 'no-matching-version' | 'transient-network' | 'fetch-timeout' | 'unexpected-store' | 'patch-failed' | 'missing-tarball-integrity' | 'windows-file-locked' | 'pnpm-unusable' | 'missing-local-dependency' | 'unparseable-build-key' | 'native-oom';
37
37
  /** Bilingual, actionable message shown to the user instead of the raw wall of text. */
38
38
  message: string;
39
39
  /** True when re-running `pnpm install` in the profile is the documented recovery. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.59.0",
4
+ "version": "1.60.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/check.ts CHANGED
@@ -947,7 +947,19 @@ export function buildBundleLayers(
947
947
  // fatal verdict and rolled back a good update (#369) — while `dsh
948
948
  // --dump-config` on the same profile exited 0. Unknown has to read as
949
949
  // unknown; the profile's own bundles are still judged normally.
950
- if (hostProvided) {
950
+ //
951
+ // The same holds for any official bundle while the installation itself
952
+ // cannot be located (#676): a desktop build ships more in-box bundles
953
+ // than the three named in INBOX_BUNDLES —
954
+ // `@deepseek-ai/dsh-experimental-agent-team-profile` among them — and a
955
+ // fixed list, or an install-path shape, cannot know which. Two
956
+ // reporters saw that bundle called "not installed — will fail to boot"
957
+ // while its three entries were active in the running host. Only
958
+ // DeepSeek publishes under `@deepseek-ai/`, so while the installation
959
+ // is out of sight such a bundle is unknown, not missing. When the
960
+ // installation IS located and the bundle is in neither it nor the
961
+ // profile, the fatal verdict below still applies.
962
+ if (hostProvided || (dshInstallDir === null && name.startsWith('@deepseek-ai/'))) {
951
963
  layer.error = null
952
964
  layer.unresolvedInbox = true
953
965
  return layer
@@ -1490,6 +1490,8 @@ export function MarketSection(props: MarketSectionProps) {
1490
1490
  * real switch state so hand-edited cordis.patch.yml toggles are visible.
1491
1491
  */
1492
1492
  const [patchDisabledNames, setPatchDisabledNames] = useState<string[]>([])
1493
+ /** Bundle packages DSH's own plugin page turned off by leaving them out of dsh.profile.bundles (#696). */
1494
+ const [unbundledNames, setUnbundledNames] = useState<string[]>([])
1493
1495
  const [groups, setGroups] = useState<Record<string, string[]>>({})
1494
1496
  const [groupOrder, setGroupOrder] = useState<string[]>([])
1495
1497
  /** Installed-tab sub-view: flat list or groups (All-plugins was removed —
@@ -1669,6 +1671,7 @@ export function MarketSection(props: MarketSectionProps) {
1669
1671
  setNotes(body.notes as Record<string, string>)
1670
1672
  }
1671
1673
  if (Array.isArray(body.patchDisabled)) setPatchDisabledNames(body.patchDisabled)
1674
+ if (Array.isArray(body.unbundled)) setUnbundledNames(body.unbundled)
1672
1675
  if (body.groups && typeof body.groups === 'object') setGroups(body.groups)
1673
1676
  if (Array.isArray(body.groupOrder)) setGroupOrder(body.groupOrder)
1674
1677
  if (Array.isArray(body.favorites)) setFavoriteUrls(body.favorites.filter((url: unknown): url is string => typeof url === 'string'))
@@ -1697,8 +1700,8 @@ export function MarketSection(props: MarketSectionProps) {
1697
1700
  const favoriteUrlSet = useMemo(() => new Set(favoriteUrls), [favoriteUrls])
1698
1701
  /** Effective switch state: market disable list ∪ user-patch-layer disables. */
1699
1702
  const effectiveDisabledSet = useMemo(
1700
- () => new Set([...disabledNames, ...patchDisabledNames]),
1701
- [disabledNames, patchDisabledNames],
1703
+ () => new Set([...disabledNames, ...patchDisabledNames, ...unbundledNames]),
1704
+ [disabledNames, patchDisabledNames, unbundledNames],
1702
1705
  )
1703
1706
 
1704
1707
  useEffect(() => {
@@ -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
@@ -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
@@ -1948,10 +1987,21 @@ export function mountMarketRoutes(
1948
1987
  return typeof manifest === 'object' && manifest !== null
1949
1988
  && (manifest as { dsh?: unknown }).dsh !== undefined
1950
1989
  }
1990
+ // A package that declares a bundle but is not in dsh.profile.bundles
1991
+ // is not composed at boot: DSH's own plugin page turns a package off
1992
+ // by removing it from that list, and nothing the market recorded
1993
+ // says so (#696). Reported as off, unless it is live right now — a
1994
+ // hot mount from this session still runs until the next restart.
1995
+ const composedBundles = new Set(readProfileBundles(activeProfileDir))
1996
+ const unbundled = Object.keys(installed).filter(name => {
1997
+ if (INBOX_BUNDLES.has(name) || composedBundles.has(name) || live.has(name)) return false
1998
+ const manifest = installedManifests.get(name) as { dsh?: { bundle?: unknown } } | null | undefined
1999
+ return typeof manifest === 'object' && manifest !== null && manifest.dsh?.bundle !== undefined
2000
+ })
1951
2001
  for (const name of Object.keys(installed)) {
1952
2002
  const result = activationAfterReplace(
1953
2003
  verifyActivation(config.profile, name, live, activeProfileDir,
1954
- disabled.has(name) || patchFlags.disabled.includes(name)),
2004
+ disabled.has(name) || patchFlags.disabled.includes(name) || unbundled.includes(name)),
1955
2005
  replacedWhileLive.has(name),
1956
2006
  )
1957
2007
  // A package with no dsh surface of its own, outside the bundle
@@ -1989,6 +2039,7 @@ export function mountMarketRoutes(
1989
2039
  favorites: readMarketState(activeProfileDir).favorites ?? [],
1990
2040
  patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts },
1991
2041
  patchDisabled: patchFlags.disabled,
2042
+ unbundled,
1992
2043
  patchForced: patchFlags.forced,
1993
2044
  bundles: readProfileBundles(activeProfileDir).filter(name => !INBOX_BUNDLES.has(name)),
1994
2045
  })
@@ -2399,13 +2450,23 @@ export function mountMarketRoutes(
2399
2450
  // disabled) is NOT dropped: #147 requires disabling it to leave the
2400
2451
  // neighbour live, and the e2e fixture-cross re-enable breaks otherwise.
2401
2452
  const disablesOthers = carrierDisableIds(activeProfileDir, name)
2453
+ // Turned off by DSH's own plugin page, which removes a package from
2454
+ // dsh.profile.bundles (#696): enabling it here has to put it back,
2455
+ // or the patch rows flip, the switch reads on, and nothing composes
2456
+ // it on the next boot. Unlike a carrier this does NOT force a
2457
+ // restart: the enable below still brings it up in this process, and
2458
+ // whether a restart is needed is whether it is live afterwards.
2459
+ const reBundle = enabled && disablesOthers.length === 0 && !INBOX_BUNDLES.has(name)
2460
+ && declaresBundle(activeProfileDir, name) && !readProfileBundles(activeProfileDir).includes(name)
2402
2461
  const isCarrier = disablesOthers.length > 0
2403
2462
  let bundleSwitch: { ok: boolean; reason: string | null } = { ok: true, reason: null }
2404
- if (isCarrier) {
2463
+ if (isCarrier || reBundle) {
2405
2464
  try {
2406
2465
  if (enabled) addProfileBundle(activeProfileDir, name)
2407
2466
  else removeProfileBundle(activeProfileDir, name)
2408
- logEvent('info', 'toggle', `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`)
2467
+ logEvent('info', 'toggle', reBundle
2468
+ ? `${name}: re-added to dsh.profile.bundles, which another manager had removed it from (#696)`
2469
+ : `${name}: disable-carrier ${enabled ? 're-added to' : 'removed from'} dsh.profile.bundles (disables: ${disablesOthers.join(', ')})`)
2409
2470
  } catch (error) {
2410
2471
  bundleSwitch = { ok: false, reason: error instanceof Error ? error.message : String(error) }
2411
2472
  logEvent('warn', 'toggle', `${name}: carrier bundle switch failed — ${bundleSwitch.reason}`)
@@ -4792,6 +4853,12 @@ sendJson(response, 200, { updates })
4792
4853
  // keep their partial state on purpose (the user sees the diff
4793
4854
  // and decides).
4794
4855
  const manifestBefore = readProfileManifestSnapshot(config.profile, activeProfileDir)
4856
+ // The lockfile too (#701): pnpm writes it before it links, so a
4857
+ // run that dies in between — a native crash, a kill — leaves a
4858
+ // lock that names a package the manifest never got. The update
4859
+ // route has always restored both; a fresh install restored only
4860
+ // the manifest and left the half for the next pnpm run to trip on.
4861
+ const lockfileBefore = captureProfileLockfile()
4795
4862
  const pinned = target === pinnedTarget && pinnedTarget !== plainTarget
4796
4863
  let result = await (pinned ? runPluginKeepingReleaseAge : runPlugin)(config.profile, ['add', target])
4797
4864
  // Two ways a pinned add can fail that a bare add would not, and
@@ -4825,6 +4892,10 @@ sendJson(response, 200, { updates })
4825
4892
  if ((result.exitCode !== 0 || result.timedOut) && !cancelled) {
4826
4893
  const rolledBack = restoreProfileManifest(config.profile, manifestBefore, activeProfileDir)
4827
4894
  if (rolledBack.length > 0) logEvent('warn', 'install', `${target}: rolled back manifest residue of the failed run: ${rolledBack.join(', ')}`)
4895
+ if (lockfileBefore.ok) {
4896
+ const lock = restoreProfileLockfile(lockfileBefore.snapshot)
4897
+ if (!lock.ok) logEvent('warn', 'install', `${target}: could not restore pnpm-lock.yaml after the failed run: ${lock.detail ?? 'unknown'}`)
4898
+ }
4828
4899
  }
4829
4900
  let ok = result.exitCode === 0 && !result.timedOut && !cancelled
4830
4901
  const cancelDiff = cancelled ? changedSince(beforeSpecs) : null