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/client/client.js +102 -19
- package/lib/check.js +36 -1
- package/lib/dsh-cli.js +12 -1
- package/lib/install.js +31 -5
- package/lib/net.js +30 -0
- package/lib/official-desktop.js +5 -1
- package/lib/profile.js +14 -9
- package/lib/trial.js +11 -2
- package/lib/types/check.d.ts +21 -0
- package/lib/types/net.d.ts +25 -0
- package/lib/types/official-desktop.d.ts +1 -0
- package/package.json +1 -1
- package/src/check.ts +36 -1
- package/src/client/Market.module.css +16 -4
- package/src/client/MarketSection.tsx +28 -3
- package/src/client/locales.ts +2 -0
- package/src/client/market-data.ts +71 -13
- package/src/dsh-cli.ts +12 -1
- package/src/install.ts +29 -5
- package/src/net.ts +34 -0
- package/src/official-desktop.ts +6 -2
- package/src/profile.ts +14 -8
- package/src/trial.ts +9 -2
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
*
|
package/lib/official-desktop.js
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}` });
|
package/lib/types/check.d.ts
CHANGED
|
@@ -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
|
package/lib/types/net.d.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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>
|
package/src/client/locales.ts
CHANGED
|
@@ -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
|
-
|
|
1082
|
-
|
|
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
|
-
/**
|
|
1118
|
-
|
|
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
|
|
1123
|
-
if (
|
|
1124
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
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
|
/**
|