dshmarket 1.66.21 → 1.66.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/install.js CHANGED
@@ -328,10 +328,16 @@ export async function withHoistRecovery(run, profile, pluginArgs, profileDirecto
328
328
  // no options from us — but the classified message above says the market
329
329
  // retried. Say that it did not, or the user reads a retry that never
330
330
  // happened and looks for the wrong cause.
331
- result = {
332
- ...result,
333
- stderr: `${result.stderr}\n\n这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`,
334
- };
331
+ //
332
+ // The wait on a fresh release is the one of these where "no retry" leaves
333
+ // the user with nothing to do at all: the plugin IS published, pnpm just
334
+ // will not take it yet, and the door the market would open on a plain
335
+ // `dsh web` host is shut here — so say when the same button starts
336
+ // working, and what to use meanwhile (#852).
337
+ const note = unavailableOption === RELEASE_AGE_OVERRIDE
338
+ ? `这个插件的新版本比本 profile 的 minimumReleaseAge(新版本等待期)还新,pnpm 因此拒绝了本次改动(改别的插件也一样)。官方桌面端由应用自己执行安装,不接受市场的临时放行参数(${unavailableOption}),所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**;想现在就更新,可以在普通的 dsh web 里装。 / The plugin's new version is younger than this profile's minimumReleaseAge, so pnpm refused this change (a change to another plugin too). The official desktop app runs the install itself and takes no one-shot bypass from the market (${unavailableOption}), so no automatic retry happens here. **Update again once the version is past the wait**; to update right now, install from plain dsh web.`
339
+ : `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`;
340
+ result = { ...result, stderr: `${result.stderr}\n\n${note}` };
335
341
  }
336
342
  return result;
337
343
  }
@@ -426,7 +432,27 @@ export function failureDetail(result, limit = 300) {
426
432
  const code = result.pnpmErrorCode === undefined ? '' : `${result.pnpmErrorCode}: `;
427
433
  return `${code}${result.pnpmError}`.slice(0, limit);
428
434
  }
429
- return (result.stderr || result.stdout).slice(-limit);
435
+ const text = (result.stderr || result.stdout).trim();
436
+ if (text.length <= limit)
437
+ return text;
438
+ const tail = text.slice(-limit);
439
+ // Cut at a sentence end instead of mid-word, and say that something was
440
+ // dropped (#852). The market appends its own explanation to pnpm's output,
441
+ // and this tail is catalogued into user-facing sentences — including inside
442
+ // a parenthesis. A blunt cut landed inside the previous sentence and welded
443
+ // the fragment to the next one: the reporter's log read
444
+ // `(is, export the log and report it这台宿主…`, which is not a sentence in
445
+ // either language and says nothing about what to do.
446
+ const sentenceEnd = /[.!?。!?]["')\])]?[\s\n]+/gu;
447
+ const match = sentenceEnd.exec(tail);
448
+ if (match !== null) {
449
+ const rest = tail.slice(match.index + match[0].length).trim();
450
+ // A boundary so near the end would leave a word or two; the raw tail is
451
+ // more use than the stub.
452
+ if (rest.length >= 40)
453
+ return `… ${rest}`;
454
+ }
455
+ return `…${tail.trim()}`;
430
456
  }
431
457
  /**
432
458
  * Some registry entries point at collection repos whose actual plugin lives
package/lib/net.js CHANGED
@@ -30,6 +30,36 @@
30
30
  * networking stays as the host configured it.
31
31
  */
32
32
  import { Agent, EnvHttpProxyAgent, fetch as undiciFetch } from 'undici';
33
+ /**
34
+ * Hand the host's own undici the dispatcher our import took (#861).
35
+ *
36
+ * undici's `lib/global.js` runs at import time, and when its symbol is unset
37
+ * it installs an Agent on BOTH global dispatcher symbols: `.1`, which Node's
38
+ * built-in fetch reads, and `.2`, which undici 8's `getGlobalDispatcher()`
39
+ * reads. `setGlobalDispatcher` defines them non-configurable, so the value
40
+ * cannot be taken back — but it is writable, and undici 8 only ever READS
41
+ * `.2`, so hiding ours there is enough for it to install its own dispatcher
42
+ * and wrap it onto `.1` for the legacy consumers.
43
+ *
44
+ * Measured on Node 24.20 and 25.6 (undici 7.29 as the market's copy, 8.12 as
45
+ * the host's): with this, a host importing undici 8 after the market gets
46
+ * `getGlobalDispatcher() instanceof its own Agent` — without it, it is handed
47
+ * the market's undici 7 Agent (`instanceof` its own Agent: false) and never
48
+ * installs one, which is the state #861 reports as the host's own LLM and
49
+ * model-list requests failing once the market is loaded.
50
+ *
51
+ * `.1` is deliberately left alone: Node's built-in fetch reads it and asserts
52
+ * it is set, so clearing it breaks global fetch outright (measured: the same
53
+ * probe that makes `.2` undefined fails `assert(dispatcher)` when `.1` is
54
+ * cleared too). Our own requests are unaffected either way — every one of
55
+ * them carries a dispatcher this module created.
56
+ */
57
+ export function releaseHostUndiciDispatcher(global = globalThis, ownAgent = Agent) {
58
+ const symbol = Symbol.for('undici.globalDispatcher.2');
59
+ if (global[symbol] instanceof ownAgent)
60
+ global[symbol] = undefined;
61
+ }
62
+ releaseHostUndiciDispatcher();
33
63
  /**
34
64
  * The proxy this process would use for the catalog, if any.
35
65
  *
@@ -1,6 +1,7 @@
1
1
  /** The official Electron profile is owned by DSH's in-process plugin manager. */
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { progress, TARGET_RE } from './dsh-cli.js';
4
+ import { activeRegion, DEFAULT_NPM_REGISTRY, routesFor } from './regions.js';
4
5
  /**
5
6
  * What a user can actually do when the market cannot perform an operation on
6
7
  * the official Desktop profile: the app's own Plugins page runs the same
@@ -86,9 +87,12 @@ _profileDirectory) {
86
87
  progress.cancelling = false;
87
88
  let operation;
88
89
  try {
90
+ // Read at each operation so region switches apply without a restart.
91
+ // The default route leaves the host's own npm configuration in charge.
92
+ const registry = routesFor(activeRegion()).npmRegistry;
89
93
  operation = command === 'remove'
90
94
  ? manager.removeBundle(target)
91
- : manager.installBundle(spec, { requestId });
95
+ : manager.installBundle(spec, { requestId, ...(registry === DEFAULT_NPM_REGISTRY ? {} : { registry }) });
92
96
  }
93
97
  catch (error) {
94
98
  progress.active = false;
package/lib/profile.js CHANGED
@@ -846,7 +846,8 @@ export function parsePatchRows(text) {
846
846
  // `insert:` indentation closes the block — those target OTHER plugins.
847
847
  let insertIndent = null;
848
848
  // Indent of a `config:` key whose value is payload, not more rows.
849
- // Everything deeper than it is data, and its `id:` keys live in another
849
+ // Everything deeper than it — and a `- ` at its own column, a sequence
850
+ // YAML lets sit unindented under its key — is data, and its `id:` keys live in another
850
851
  // namespace (#841): a preset row writes `config.plugins[].id` for the
851
852
  // entries it will instantiate, so two bundles that instantiate the same
852
853
  // official plugins read as two bundles inserting the same entry ids —
@@ -905,19 +906,23 @@ export function parsePatchRows(text) {
905
906
  }
906
907
  return false;
907
908
  };
908
- /** Whether the key at `keyIndent` holds a block sequence. */
909
+ /**
910
+ * Whether the key at `keyIndent` holds a block sequence. YAML lets that
911
+ * sequence sit at the key's own column (`config:` then `- id:` without
912
+ * further indent), so a `- ` there is the value, not a sibling.
913
+ */
909
914
  const valueIsList = (from, keyIndent) => {
910
- for (let j = from + 1; j < rows.length; j++) {
911
- if (rows[j].indent <= keyIndent)
912
- return false;
913
- return rows[j].trimmed === '-' || rows[j].trimmed.startsWith('- ');
914
- }
915
- return false;
915
+ const next = from + 1;
916
+ if (next >= rows.length || rows[next].indent < keyIndent)
917
+ return false;
918
+ return isDash(next);
916
919
  };
917
920
  for (let i = 0; i < rows.length; i++) {
918
921
  const { line, trimmed, indent } = rows[i];
919
- if (payloadIndent !== null && indent <= payloadIndent)
922
+ // A `- ` at the payload key's own column is still that key's value.
923
+ if (payloadIndent !== null && indent <= payloadIndent && !(indent === payloadIndent && isDash(i))) {
920
924
  payloadIndent = null;
925
+ }
921
926
  if (insertIndent !== null && indent <= insertIndent && !/^\s*-?\s*(id|name|config):/u.test(line)) {
922
927
  insertIndent = null;
923
928
  }
package/lib/trial.js CHANGED
@@ -25,7 +25,7 @@
25
25
  */
26
26
  import { existsSync, readFileSync } from 'node:fs';
27
27
  import { join } from 'node:path';
28
- import { buildBundleLayers, composeLayers, findDshInstallDir, parsePatchFile, } from './check.js';
28
+ import { buildBundleLayers, composeLayers, duplicateEntryIdsAreFatal, findDshInstallDir, parsePatchFile, } from './check.js';
29
29
  import { resolveDshHome } from './home-paths.js';
30
30
  import { mergeOrder, readBundleStack } from './order.js';
31
31
  /**
@@ -89,8 +89,17 @@ export function trialValidate(profileDir, newCommunityOrder, options = {}) {
89
89
  if (bundle.parseError !== null)
90
90
  errors.push({ layer: bundle.name, message: bundle.parseError });
91
91
  }
92
+ // Same version gate as the check report (#853): on a loader that folds one
93
+ // id into one entry (1.0.4+) this is an override, not a refusal. Making it
94
+ // fatal here is what rolled back every update for a profile that already
95
+ // had a package row and a user-patch row on one id.
96
+ const duplicateFatal = duplicateEntryIdsAreFatal(profileDir);
92
97
  for (const dup of composed.duplicates) {
93
- errors.push({ layer: dup.layers.join(' / '), message: `duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows) / 重复的 loader 条目 id ${JSON.stringify(dup.id)}` });
98
+ const message = `duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows) / 重复的 loader 条目 id ${JSON.stringify(dup.id)}`;
99
+ if (duplicateFatal)
100
+ errors.push({ layer: dup.layers.join(' / '), message });
101
+ else
102
+ warnings.push({ layer: dup.layers.join(' / '), message: `${message} — 当前宿主会让后一层覆盖前一层,启动不受影响 / this host lets the later layer win, so the boot is unaffected` });
94
103
  }
95
104
  for (const orphan of composed.orphans) {
96
105
  warnings.push({ layer: orphan.layer, message: `${orphan.id}: ${orphan.reason}` });
@@ -285,6 +285,27 @@ interface Composed {
285
285
  * composition with a candidate bundle order BEFORE anything is written.
286
286
  */
287
287
  export declare function composeLayers(layers: LayerInput[]): Composed;
288
+ /**
289
+ * Whether two rows sharing one loader id still stop the boot (#853).
290
+ *
291
+ * It depends on the loader, and the loader changed: up to
292
+ * `cordis-plugin-loader@1.0.3` `EntryGroup.update` threw
293
+ * `duplicate loader entry id` for a config array holding one id twice
294
+ * (measured against the published packages — 1.0.3 has the throw, 1.0.4 and
295
+ * 1.0.5 do not). From 1.0.4 the same array is folded into a Map keyed by id,
296
+ * so the LATER row wins and the earlier one is silently dropped.
297
+ *
298
+ * The market's rule was written against the old loader (#122: two bundles on
299
+ * one id really did refuse to start), and it is right on one. On a new one it
300
+ * is not a boot failure but a silent override — which is worth SAYING and not
301
+ * worth refusing every operation over. The reported shape is a package's own
302
+ * patch row plus a user-patch row on the same id: fatal forever meant every
303
+ * update of every plugin rolled back.
304
+ *
305
+ * Unknown version stays fatal: the conservative direction, and the case where
306
+ * the evidence to be less strict is missing.
307
+ */
308
+ export declare function duplicateEntryIdsAreFatal(profileDirectory: string): boolean;
288
309
  /**
289
310
  * Build the bundle layer stack for a profile under a GIVEN bundle order —
290
311
  * the manifest order for analyzeProfile, or a candidate order for trial
@@ -29,6 +29,31 @@
29
29
  * plain `Agent` otherwise. Only requests made here take it. The host's own
30
30
  * networking stays as the host configured it.
31
31
  */
32
+ /**
33
+ * Hand the host's own undici the dispatcher our import took (#861).
34
+ *
35
+ * undici's `lib/global.js` runs at import time, and when its symbol is unset
36
+ * it installs an Agent on BOTH global dispatcher symbols: `.1`, which Node's
37
+ * built-in fetch reads, and `.2`, which undici 8's `getGlobalDispatcher()`
38
+ * reads. `setGlobalDispatcher` defines them non-configurable, so the value
39
+ * cannot be taken back — but it is writable, and undici 8 only ever READS
40
+ * `.2`, so hiding ours there is enough for it to install its own dispatcher
41
+ * and wrap it onto `.1` for the legacy consumers.
42
+ *
43
+ * Measured on Node 24.20 and 25.6 (undici 7.29 as the market's copy, 8.12 as
44
+ * the host's): with this, a host importing undici 8 after the market gets
45
+ * `getGlobalDispatcher() instanceof its own Agent` — without it, it is handed
46
+ * the market's undici 7 Agent (`instanceof` its own Agent: false) and never
47
+ * installs one, which is the state #861 reports as the host's own LLM and
48
+ * model-list requests failing once the market is loaded.
49
+ *
50
+ * `.1` is deliberately left alone: Node's built-in fetch reads it and asserts
51
+ * it is set, so clearing it breaks global fetch outright (measured: the same
52
+ * probe that makes `.2` undefined fails `assert(dispatcher)` when `.1` is
53
+ * cleared too). Our own requests are unaffected either way — every one of
54
+ * them carries a dispatcher this module created.
55
+ */
56
+ export declare function releaseHostUndiciDispatcher(global?: Record<symbol, unknown>, ownAgent?: abstract new (...args: never[]) => unknown): void;
32
57
  /**
33
58
  * The proxy this process would use for the catalog, if any.
34
59
  *
@@ -11,6 +11,7 @@ interface ManagedResult {
11
11
  export interface OfficialPluginManagerLike {
12
12
  installBundle(spec: string, options?: {
13
13
  requestId?: string;
14
+ registry?: string;
14
15
  }): Promise<ManagedResult>;
15
16
  removeBundle(name: string): Promise<ManagedResult>;
16
17
  cancelInstall(requestId: string): Promise<unknown>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dshmarket",
3
3
  "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。",
4
- "version": "1.66.21",
4
+ "version": "1.66.23",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
package/src/check.ts CHANGED
@@ -937,6 +937,33 @@ export function composeLayers(layers: LayerInput[]): Composed {
937
937
  return { rows, resolvableRows, duplicates, overrides, orphans }
938
938
  }
939
939
 
940
+ /**
941
+ * Whether two rows sharing one loader id still stop the boot (#853).
942
+ *
943
+ * It depends on the loader, and the loader changed: up to
944
+ * `cordis-plugin-loader@1.0.3` `EntryGroup.update` threw
945
+ * `duplicate loader entry id` for a config array holding one id twice
946
+ * (measured against the published packages — 1.0.3 has the throw, 1.0.4 and
947
+ * 1.0.5 do not). From 1.0.4 the same array is folded into a Map keyed by id,
948
+ * so the LATER row wins and the earlier one is silently dropped.
949
+ *
950
+ * The market's rule was written against the old loader (#122: two bundles on
951
+ * one id really did refuse to start), and it is right on one. On a new one it
952
+ * is not a boot failure but a silent override — which is worth SAYING and not
953
+ * worth refusing every operation over. The reported shape is a package's own
954
+ * patch row plus a user-patch row on the same id: fatal forever meant every
955
+ * update of every plugin rolled back.
956
+ *
957
+ * Unknown version stays fatal: the conservative direction, and the case where
958
+ * the evidence to be less strict is missing.
959
+ */
960
+ export function duplicateEntryIdsAreFatal(profileDirectory: string): boolean {
961
+ const versions = lockfileCoreVersions(profileDirectory).get('@deepseek-ai/cordis-plugin-loader') ?? []
962
+ if (versions.length === 0) return true
963
+ const newest = versions.reduce((a, b) => (compareSemver(a, b) >= 0 ? a : b))
964
+ return compareSemver(newest, '1.0.4') < 0
965
+ }
966
+
940
967
  /** Distinct versions of `@deepseek-ai/{dsh,cordis}*` packages in the lockfile. */
941
968
  function lockfileCoreVersions(profileDir: string): Map<string, string[]> {
942
969
  const found = new Map<string, Set<string>>()
@@ -1509,8 +1536,16 @@ export function analyzeProfile(profileDirectory: string, options: CheckOptions =
1509
1536
  if (row.activation === 'required') errors.push(`${message} — the profile will fail to boot`)
1510
1537
  else warnings.push(`${message} — boot will fail if its disabled expression enables the entry`)
1511
1538
  }
1539
+ // Rows on one id: a boot failure on an old loader, a silent override on a
1540
+ // new one (see duplicateEntryIdsAreFatal). Refusing an operation over the
1541
+ // second shape rolled back every update for the reporter's profile.
1542
+ const duplicateFatal = duplicateEntryIdsAreFatal(profileDirectory)
1512
1543
  for (const dup of composed.duplicates) {
1513
- errors.push(`duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows: ${dup.layers.join(', ')})`)
1544
+ if (duplicateFatal) {
1545
+ errors.push(`duplicate loader entry id ${JSON.stringify(dup.id)} (${dup.count} rows: ${dup.layers.join(', ')})`)
1546
+ continue
1547
+ }
1548
+ warnings.push(`${dup.layers.join(' 与 ')}:同一个条目 id ${JSON.stringify(dup.id)} 各有 ${String(dup.count)} 行,当前宿主会让后一层覆盖前一层,启动不受影响,但前面那行会静默失效——删除其中一行即可消除歧义 / the layers ${dup.layers.join(' and ')} each declare entry id ${JSON.stringify(dup.id)} (${String(dup.count)} rows); this host lets the LATER row win, so the boot is fine but the earlier row is silently dropped — remove one of them to make the intent unambiguous`)
1514
1549
  }
1515
1550
  for (const orphan of composed.orphans) {
1516
1551
  warnings.push(`${orphan.layer}: ${orphan.id} — ${orphan.reason}`)
@@ -11,8 +11,9 @@
11
11
  .sub{display:flex;align-items:center;gap:8px;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,#8b93a1);margin:0}
12
12
  .submitLink{font-size:11px;line-height:18px;color:var(--dsw-alias-label-tertiary,#8b93a1);text-decoration:none;white-space:nowrap}
13
13
  .submitLink:hover{color:var(--dsw-alias-brand-primary,#4f6ef7);text-decoration:underline}
14
- /* Wrap instead of clipping controls or the Tasks popover in narrow panels. */
15
- .tabs{display:flex;flex-wrap:wrap;gap:2px;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e7eb);align-items:flex-end}
14
+ /* Wrap instead of clipping controls or the Tasks popover in narrow panels.
15
+ position:relative is the popover's anchor — see .opPanel (#866). */
16
+ .tabs{position:relative;display:flex;flex-wrap:wrap;gap:2px;border-bottom:1px solid var(--dsw-alias-border-l2,#e5e7eb);align-items:flex-end}
16
17
  .tab{border:none;background:none;font:inherit;font-size:13px;color:var(--dsw-alias-label-secondary,#6b7280);padding:7px 12px;cursor:pointer;border-bottom:2px solid transparent;white-space:nowrap}
17
18
  .tab.on{color:var(--dsw-alias-brand-primary,#4f6ef7);border-bottom-color:var(--dsw-alias-brand-primary,#4f6ef7);font-weight:600}
18
19
  /* Second-level tabs under "Advanced" — same button styling as .tabs so the
@@ -619,7 +620,15 @@
619
620
  /* Lift the entry off the tab row's bottom rule — a pill button flush on the
620
621
  divider reads as clipped, especially when export log sits one row above on
621
622
  narrow viewports. */
622
- .opWrap{position:relative;display:inline-flex;flex-shrink:0;margin-bottom:6px}
623
+ /* NOT the popover's containing block (#866): this entry sits at the right end
624
+ of the tab row, so when the row wraps it lands at the LEFT edge of the
625
+ second line — and a panel anchored `right:0` to it would then open to the
626
+ left, past the settings panel's edge. The host's panel is `overflow:hidden`,
627
+ and LTR inline-start overflow is unreachable by scrolling, so the panel's
628
+ title, icons and the first half of every plugin name were simply gone. The
629
+ row is the anchor instead, so the panel hangs from the container's right
630
+ edge wherever the entry ends up. */
631
+ .opWrap{display:inline-flex;flex-shrink:0;margin-bottom:6px}
623
632
  .opEntry{display:inline-flex;align-items:center;gap:6px;font:inherit;font-size:12px;
624
633
  padding:4px 10px;border-radius:7px;cursor:pointer;position:relative;white-space:nowrap;
625
634
  color:var(--dsw-alias-label-primary,#1f2328);
@@ -630,7 +639,10 @@
630
639
  .opEntryAlert{border-color:var(--dsw-alias-state-error-primary,#dc2626);color:var(--dsw-alias-state-error-primary,#dc2626)}
631
640
  .opDot{position:absolute;top:-3px;right:-3px;width:8px;height:8px;border-radius:99px;
632
641
  background:var(--dsw-alias-state-error-primary,#dc2626)}
633
- .opPanel{position:absolute;top:calc(100% + 6px);right:0;z-index:40;width:460px;max-width:86vw;
642
+ /* max-width is a percentage of the row, not of the viewport: the panel must
643
+ never be wider than the row it hangs from, or the part past the container's
644
+ left edge is clipped unreachably (#866). */
645
+ .opPanel{position:absolute;top:calc(100% + 6px);right:0;z-index:40;width:460px;max-width:100%;
634
646
  max-height:70vh;overflow-y:auto;border-radius:12px;
635
647
  background:var(--dsw-alias-bg-layer-1,#fff);
636
648
  border:1px solid var(--dsw-alias-border-l3,#d9dde3);
@@ -4462,7 +4462,23 @@ export function MarketSection(props: MarketSectionProps) {
4462
4462
  // The market manages itself from its own settings card (Settings → Plugins
4463
4463
  // → Plugin configuration), not as a row here — listing it in both places
4464
4464
  // read as two different controls for the same thing.
4465
- const installedOtherCount = Object.keys(installed).filter(name => name !== selfName).length
4465
+ /**
4466
+ * Installed packages that are not plugins at all: a plain library some
4467
+ * other plugin pulled in — zustand, bufferutil, a peer of ws (#634's
4468
+ * `dependencyOf`, #857). They were rendered as plugin cards with a
4469
+ * working uninstall, which is a real remove path for something a plugin
4470
+ * needs. Out of the list; the count stays visible in one quiet line, so the
4471
+ * list is not silently shorter than package.json.
4472
+ */
4473
+ const dependencyOnlyNames = useMemo(
4474
+ () => new Set(Object.keys(installed).filter(name => {
4475
+ const act = activations[name]
4476
+ return act?.state === 'inert' && act.dependencyOf !== undefined
4477
+ })),
4478
+ [installed, activations],
4479
+ )
4480
+ const installedOtherCount = Object.keys(installed)
4481
+ .filter(name => name !== selfName && !dependencyOnlyNames.has(name)).length
4466
4482
 
4467
4483
  const ignoreUpdateNotices = useCallback((names: string[]) => {
4468
4484
  if (bootId === null || names.length === 0) return
@@ -4759,6 +4775,7 @@ export function MarketSection(props: MarketSectionProps) {
4759
4775
  const orderedInstalledEntries = useMemo(() => {
4760
4776
  return Object.entries(displayedInstalled)
4761
4777
  .filter(([name]) => name !== selfName)
4778
+ .filter(([name]) => !dependencyOnlyNames.has(name))
4762
4779
  .sort(([nameA, specA], [nameB, specB]) => {
4763
4780
  // The list reads as "what is running", so a plugin that is off sinks
4764
4781
  // below every one that is on (#745) — even a disabled row with a
@@ -4771,7 +4788,7 @@ export function MarketSection(props: MarketSectionProps) {
4771
4788
  return bUp - aUp
4772
4789
  })
4773
4790
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `updatesLoaded` stands in for `updates`/`updatedNames`: reorder when the check lands, then hold (#631). `effectiveDisabledSet` only re-runs this when a disable state actually changed; the toggled row is the only one that moves.
4774
- }, [isInstalledListActive, displayedInstalled, selfName, updatesLoaded, effectiveDisabledSet])
4791
+ }, [isInstalledListActive, displayedInstalled, selfName, updatesLoaded, effectiveDisabledSet, dependencyOnlyNames])
4775
4792
  const missingRestoreCount = Object.keys(pendingDependencies).filter(name => !installedFiles.includes(name)).length
4776
4793
  // Self-update lives in the header button and the settings card, not this
4777
4794
  // tab's row list (the market itself is filtered out below) — so a pending
@@ -5520,7 +5537,8 @@ export function MarketSection(props: MarketSectionProps) {
5520
5537
  )
5521
5538
 
5522
5539
  /** Installed plugins the market itself cannot group (#60). */
5523
- const groupableNames = Object.keys(installed).filter(name => name !== 'dsh-market' && name !== 'dshmarket')
5540
+ const groupableNames = Object.keys(installed)
5541
+ .filter(name => name !== 'dsh-market' && name !== 'dshmarket' && !dependencyOnlyNames.has(name))
5524
5542
  /** Names already inside some group; everything else shows under "ungrouped". */
5525
5543
  const groupedNames = useMemo(() => new Set(Object.values(groups).flat()), [groups])
5526
5544
  const ungroupedNames = groupableNames.filter(name => !groupedNames.has(name))
@@ -7045,6 +7063,13 @@ export function MarketSection(props: MarketSectionProps) {
7045
7063
  }}
7046
7064
  />
7047
7065
  )}
7066
+ {dependencyOnlyNames.size > 0 && (
7067
+ // The list is complete as a list of PLUGINS; the count keeps it
7068
+ // from looking like packages went missing (#857).
7069
+ <p className={css.groupOrgHint}>
7070
+ {t('installedDependencyNote').replace('{0}', String(dependencyOnlyNames.size))}
7071
+ </p>
7072
+ )}
7048
7073
  </>
7049
7074
  )}
7050
7075
  </div>
@@ -366,6 +366,7 @@ export const zh = {
366
366
  marketUpdate: '更新插件市场',
367
367
  updateAll: '全部更新',
368
368
  heldReleaseNotice: '已安装,但不是最新版:这个 profile 的 minimumReleaseAge 挡住了 {0},安装的是 {1}。',
369
+ installedDependencyNote: '另有 {0} 个包是插件带进来的依赖,不是插件,未列在上方。',
369
370
  heldReleaseAction: '仍安装 {0}',
370
371
  ignoreUpdateNotice: '重启前不再提醒',
371
372
  ignoreAllUpdateNotices: '重启前都不再提醒',
@@ -1096,6 +1097,7 @@ export const en: Record<MarketKey, string> = {
1096
1097
  marketUpdate: 'Update the plugin market',
1097
1098
  updateAll: 'Update all',
1098
1099
  heldReleaseNotice: 'Installed, but not the newest: this profile\'s minimumReleaseAge held {0} back, so {1} was installed.',
1100
+ installedDependencyNote: '{0} more packages came in as dependencies of plugins — they are not plugins, so they are not listed above.',
1099
1101
  heldReleaseAction: 'Install {0} anyway',
1100
1102
  ignoreUpdateNotice: "Don't remind until restart",
1101
1103
  ignoreAllUpdateNotices: "Don't remind any until restart",
@@ -1076,14 +1076,29 @@ export interface ScreenshotMeasurement {
1076
1076
  height: number
1077
1077
  }
1078
1078
 
1079
+ /** One refused candidate and the reason the image gate refused it. */
1080
+ export interface ScreenshotRejection {
1081
+ value: unknown
1082
+ reason: string
1083
+ }
1084
+
1085
+ /** The single acceptance rule: the URL to load, or the sentence explaining the refusal. */
1086
+ type ScreenshotGate = { ok: true; src: string } | { ok: false; reason: string }
1087
+
1088
+ function gateScreenshot(value: unknown): ScreenshotGate {
1089
+ if (typeof value !== 'string') return { ok: false, reason: 'not a string' }
1090
+ let parsed: URL
1091
+ try { parsed = new URL(value) } catch { return { ok: false, reason: 'not a parseable URL' } }
1092
+ if (parsed.protocol !== 'https:') return { ok: false, reason: 'not https' }
1093
+ if (!SCREENSHOT_HOSTS.has(parsed.hostname)) return { ok: false, reason: `host not allowlisted (${parsed.hostname})` }
1094
+ if (/\.svg$/iu.test(parsed.pathname)) return { ok: false, reason: 'svg is not rendered' }
1095
+ return { ok: true, src: value }
1096
+ }
1097
+
1079
1098
  /** Return one safe screenshot URL without applying the public list limit. */
1080
1099
  function safeScreenshot(value: unknown): string | null {
1081
- if (typeof value !== 'string') return null
1082
- let parsed: URL
1083
- try { parsed = new URL(value) } catch { return null }
1084
- if (parsed.protocol !== 'https:' || !SCREENSHOT_HOSTS.has(parsed.hostname)) return null
1085
- if (/\.svg$/iu.test(parsed.pathname)) return null
1086
- return value
1100
+ const gate = gateScreenshot(value)
1101
+ return gate.ok ? gate.src : null
1087
1102
  }
1088
1103
 
1089
1104
  /**
@@ -1114,14 +1129,29 @@ export function releaseNotesHttpsImage(line: string): { alt: string; src: string
1114
1129
  return { alt: match[1] ?? '', src }
1115
1130
  }
1116
1131
 
1117
- /** Keep only https URLs on allowlisted image hosts; SVG dropped (logos/badges). */
1118
- export function safeScreenshots(urls: unknown): string[] {
1132
+ /**
1133
+ * Keep only https URLs on allowlisted image hosts; SVG dropped (logos/badges).
1134
+ *
1135
+ * `onReject` is opt-in, because whether a refusal deserves a word depends on
1136
+ * where the list came from: a README contributes mostly badges, logos and
1137
+ * third-party hosts, where refusing IS the intended outcome. A curated
1138
+ * catalog list is different — every URL in it is declared intent — so
1139
+ * `pluginScreenshotCandidates` is the caller that reports (see there).
1140
+ */
1141
+ export function safeScreenshots(
1142
+ urls: unknown,
1143
+ options: { onReject?: (rejection: ScreenshotRejection) => void } = {},
1144
+ ): string[] {
1119
1145
  if (!Array.isArray(urls)) return []
1146
+ const { onReject } = options
1120
1147
  const safe: string[] = []
1121
1148
  for (const value of urls) {
1122
- const src = safeScreenshot(value)
1123
- if (src === null) continue
1124
- if (!safe.includes(src)) safe.push(src)
1149
+ const gate = gateScreenshot(value)
1150
+ if (!gate.ok) {
1151
+ onReject?.({ value, reason: gate.reason })
1152
+ continue
1153
+ }
1154
+ if (!safe.includes(gate.src)) safe.push(gate.src)
1125
1155
  if (safe.length >= MAX_SCREENSHOTS) break
1126
1156
  }
1127
1157
  return safe
@@ -1303,9 +1333,35 @@ export function rankThemeScreenshots(
1303
1333
 
1304
1334
  const readmeShotsCache = new Map<string, Promise<ScreenshotCandidate[]>>()
1305
1335
 
1306
- /** Clear README-derived media at the boundary of an accepted catalog generation. */
1336
+ /**
1337
+ * Declared screenshots the market refuses, reported once per catalog entry.
1338
+ *
1339
+ * A curated entry is the publisher's declaration, and the registry gate that
1340
+ * accepts it (`scripts/validate-registry.mjs`, E12) allows any https URL on
1341
+ * the four GitHub hosts, while this client additionally refuses `.svg`. An
1342
+ * entry can therefore pass CI with a screenshot the market then drops, and
1343
+ * until this existed nothing said so: the card rendered fewer previews, or —
1344
+ * when every declared shot was refused — the client quietly fell back to
1345
+ * scraping the README. Keyed by entry plus the refused values, so a re-render,
1346
+ * a dialog and a theme card report one line between them, and cleared with the
1347
+ * README cache at each accepted catalog generation.
1348
+ */
1349
+ const refusedCuratedScreenshots = new Set<string>()
1350
+
1351
+ function warnRefusedCuratedScreenshots(plugin: RegistryPlugin, rejections: ScreenshotRejection[]): void {
1352
+ const details = [...new Set(rejections.map(({ value, reason }) => `${reason}: ${String(value)}`))]
1353
+ const key = `${plugin.url}\n${details.join('\n')}`
1354
+ if (refusedCuratedScreenshots.has(key)) return
1355
+ refusedCuratedScreenshots.add(key)
1356
+ console.warn(
1357
+ `[dsh-market] catalog entry "${plugin.name}" declares ${details.length} screenshot(s) the market will not load — ${details.join('; ')}`,
1358
+ )
1359
+ }
1360
+
1361
+ /** Clear README-derived media and refusal reporting at the boundary of an accepted catalog generation. */
1307
1362
  export function resetScreenshotsCache(): void {
1308
1363
  readmeShotsCache.clear()
1364
+ refusedCuratedScreenshots.clear()
1309
1365
  }
1310
1366
 
1311
1367
  /**
@@ -1313,7 +1369,9 @@ export function resetScreenshotsCache(): void {
1313
1369
  * present, otherwise lazily extracted and semantically ranked from README.
1314
1370
  */
1315
1371
  export function pluginScreenshotCandidates(plugin: RegistryPlugin): Promise<ScreenshotCandidate[]> {
1316
- const curated = safeScreenshots(plugin.screenshots)
1372
+ const refused: ScreenshotRejection[] = []
1373
+ const curated = safeScreenshots(plugin.screenshots, { onReject: rejection => { refused.push(rejection) } })
1374
+ if (refused.length > 0) warnRefusedCuratedScreenshots(plugin, refused)
1317
1375
  if (curated.length > 0) {
1318
1376
  return Promise.resolve(curated.map((src, order) => ({ src, order, semanticScore: 1_000 - order, curated: true })))
1319
1377
  }
package/src/dsh-cli.ts CHANGED
@@ -136,12 +136,23 @@ export function proxyEnvForPnpm(env: NodeJS.ProcessEnv = process.env, region: Re
136
136
  // statement about where packages come from, and a region setting must not
137
137
  // overrule it. Same rule the proxy translation above follows, for the same
138
138
  // reason — this function's job is to fill silence, not to overwrite speech.
139
+ //
140
+ // Written in BOTH vocabularies (#865). pnpm reads `pnpm_config_*` /
141
+ // `PNPM_CONFIG_*` and does not read `npm_config_registry` at all — measured
142
+ // on 11.7.0, where the npm spelling is ignored and whatever `.npmrc` names
143
+ // stands. So the region setting used to move the market's own requests
144
+ // (checks, catalog, changelog) while every install still fetched from the
145
+ // user's configured registry, which is how a mirror lagging behind the
146
+ // catalog reads as "this version was never published" (#862). npm's own
147
+ // spelling stays because other things in the install path read it, and it
148
+ // is the name the value has always had.
139
149
  const mirror = routesFor(region).npmRegistry
140
- if (mirror !== DEFAULT_NPM_REGISTRY && !has('npm_config_registry')) {
150
+ if (mirror !== DEFAULT_NPM_REGISTRY && !has('npm_config_registry') && !has('PNPM_CONFIG_REGISTRY')) {
141
151
  // npm's own config convention terminates the registry with a slash;
142
152
  // pnpm accepts either, but writing it the conventional way keeps the
143
153
  // value recognizable to anyone reading the spawned process's env.
144
154
  out.npm_config_registry = `${mirror}/`
155
+ out.PNPM_CONFIG_REGISTRY = `${mirror}/`
145
156
  }
146
157
  return out
147
158
  }
package/src/install.ts CHANGED
@@ -334,10 +334,16 @@ export async function withHoistRecovery(
334
334
  // no options from us — but the classified message above says the market
335
335
  // retried. Say that it did not, or the user reads a retry that never
336
336
  // happened and looks for the wrong cause.
337
- result = {
338
- ...result,
339
- stderr: `${result.stderr}\n\n这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`,
340
- }
337
+ //
338
+ // The wait on a fresh release is the one of these where "no retry" leaves
339
+ // the user with nothing to do at all: the plugin IS published, pnpm just
340
+ // will not take it yet, and the door the market would open on a plain
341
+ // `dsh web` host is shut here — so say when the same button starts
342
+ // working, and what to use meanwhile (#852).
343
+ const note = unavailableOption === RELEASE_AGE_OVERRIDE
344
+ ? `这个插件的新版本比本 profile 的 minimumReleaseAge(新版本等待期)还新,pnpm 因此拒绝了本次改动(改别的插件也一样)。官方桌面端由应用自己执行安装,不接受市场的临时放行参数(${unavailableOption}),所以这里没有自动重试。**等这个版本过了等待期再更新一次即可**;想现在就更新,可以在普通的 dsh web 里装。 / The plugin's new version is younger than this profile's minimumReleaseAge, so pnpm refused this change (a change to another plugin too). The official desktop app runs the install itself and takes no one-shot bypass from the market (${unavailableOption}), so no automatic retry happens here. **Update again once the version is past the wait**; to update right now, install from plain dsh web.`
345
+ : `这台宿主(官方桌面端)自己执行安装,不接受市场附加的 pnpm 参数(${unavailableOption}),所以这一步没有自动重试。 / This host (the official desktop app) runs the install itself and takes no pnpm options from the market (${unavailableOption}), so that automatic retry could not be attempted.`
346
+ result = { ...result, stderr: `${result.stderr}\n\n${note}` }
341
347
  }
342
348
  return result
343
349
  }
@@ -432,7 +438,25 @@ export function failureDetail(result: InstallResult, limit = 300): string {
432
438
  const code = result.pnpmErrorCode === undefined ? '' : `${result.pnpmErrorCode}: `
433
439
  return `${code}${result.pnpmError}`.slice(0, limit)
434
440
  }
435
- return (result.stderr || result.stdout).slice(-limit)
441
+ const text = (result.stderr || result.stdout).trim()
442
+ if (text.length <= limit) return text
443
+ const tail = text.slice(-limit)
444
+ // Cut at a sentence end instead of mid-word, and say that something was
445
+ // dropped (#852). The market appends its own explanation to pnpm's output,
446
+ // and this tail is catalogued into user-facing sentences — including inside
447
+ // a parenthesis. A blunt cut landed inside the previous sentence and welded
448
+ // the fragment to the next one: the reporter's log read
449
+ // `(is, export the log and report it这台宿主…`, which is not a sentence in
450
+ // either language and says nothing about what to do.
451
+ const sentenceEnd = /[.!?。!?]["')\])]?[\s\n]+/gu
452
+ const match = sentenceEnd.exec(tail)
453
+ if (match !== null) {
454
+ const rest = tail.slice(match.index + match[0].length).trim()
455
+ // A boundary so near the end would leave a word or two; the raw tail is
456
+ // more use than the stub.
457
+ if (rest.length >= 40) return `… ${rest}`
458
+ }
459
+ return `…${tail.trim()}`
436
460
  }
437
461
 
438
462
  /**