dsh-plugin-manager-companion 0.1.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.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +144 -0
  3. package/README.md +142 -0
  4. package/cordis.patch.yml +9 -0
  5. package/dist/about.d.ts +77 -0
  6. package/dist/about.js +179 -0
  7. package/dist/cli.d.ts +226 -0
  8. package/dist/cli.js +856 -0
  9. package/dist/client/AboutPage.d.ts +75 -0
  10. package/dist/client/ConsolePage.d.ts +79 -0
  11. package/dist/client/KindsPage.d.ts +21 -0
  12. package/dist/client/MarketplacePage.d.ts +36 -0
  13. package/dist/client/OfficialSlots.d.ts +35 -0
  14. package/dist/client/UpgradeRow.d.ts +108 -0
  15. package/dist/client/index.d.ts +26 -0
  16. package/dist/client/locales.d.ts +475 -0
  17. package/dist/client/pmSelect.d.ts +38 -0
  18. package/dist/client/shared.d.ts +928 -0
  19. package/dist/client/upgradeView.d.ts +278 -0
  20. package/dist/client/wire.d.ts +401 -0
  21. package/dist/client.js +9194 -0
  22. package/dist/diagnostics.d.ts +332 -0
  23. package/dist/diagnostics.js +2631 -0
  24. package/dist/envManager.d.ts +1047 -0
  25. package/dist/envManager.js +3214 -0
  26. package/dist/fix.d.ts +60 -0
  27. package/dist/fix.js +168 -0
  28. package/dist/guard.d.ts +133 -0
  29. package/dist/guard.js +232 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.js +1150 -0
  32. package/dist/installSession.d.ts +111 -0
  33. package/dist/installSession.js +150 -0
  34. package/dist/kinds.d.ts +464 -0
  35. package/dist/kinds.js +1029 -0
  36. package/dist/marketView.d.ts +261 -0
  37. package/dist/marketView.js +406 -0
  38. package/dist/marketplace.d.ts +248 -0
  39. package/dist/marketplace.js +500 -0
  40. package/dist/match.d.ts +67 -0
  41. package/dist/match.js +203 -0
  42. package/dist/net.d.ts +108 -0
  43. package/dist/net.js +163 -0
  44. package/dist/official.d.ts +145 -0
  45. package/dist/official.js +205 -0
  46. package/dist/paths.d.ts +108 -0
  47. package/dist/paths.js +236 -0
  48. package/dist/presets.d.ts +299 -0
  49. package/dist/presets.js +578 -0
  50. package/dist/qualityGate.d.ts +66 -0
  51. package/dist/qualityGate.js +247 -0
  52. package/dist/rank.d.ts +88 -0
  53. package/dist/rank.js +164 -0
  54. package/dist/registry.d.ts +295 -0
  55. package/dist/registry.js +686 -0
  56. package/dist/rest.d.ts +122 -0
  57. package/dist/rest.js +219 -0
  58. package/dist/scan.d.ts +134 -0
  59. package/dist/scan.js +396 -0
  60. package/dist/settings.d.ts +447 -0
  61. package/dist/settings.js +263 -0
  62. package/dist/tags.d.ts +119 -0
  63. package/dist/tags.js +166 -0
  64. package/dist/tools.d.ts +131 -0
  65. package/dist/tools.js +377 -0
  66. package/dist/types.d.ts +651 -0
  67. package/dist/types.js +13 -0
  68. package/dist/upgrade.d.ts +428 -0
  69. package/dist/upgrade.js +1100 -0
  70. package/dist/upgradeView.d.ts +313 -0
  71. package/dist/upgradeView.js +273 -0
  72. package/docs/images/readme/01-console-health.png +0 -0
  73. package/docs/images/readme/02-console-envs.png +0 -0
  74. package/docs/images/readme/03-marketplace.png +0 -0
  75. package/docs/images/readme/04-official-plugin-page.png +0 -0
  76. package/package.json +104 -0
package/dist/match.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * match.ts — plugin_search 的匹配纯函数:分词、加权打分、版本比较(无 fs / 无 ctx / 无网络)。
3
+ *
4
+ * 归属:B 类·参考重写(旧 src/match.ts 仅作意图参考,未复制代码;用例语义在 tests/marketplace.test.mjs 重写)。
5
+ * 旧实现参考:dsh-web-plugin-manager/src/match.ts(理解意图用,未复制代码)——它解决的问题是:
6
+ * 模型侧的自然语言查询("帮我找记住上下文的插件")需要一个不看 UI 的纯函数打分器,
7
+ * 并且要能回答"这个装好的包是不是该更新了"(版本比较)。
8
+ * 官方复用:无。官方没有市场/搜索概念;版本比较也不能用官方 plugin_manager 工具代替
9
+ * (它做安装,不做"哪个更新"的判断)。
10
+ * 前提检查:旧实现的两条权重(名称 3 / 主题 2 / 描述 1)仍然成立,但旧审计 m-1
11
+ * (docs/private/audit/correctness.md)指出 compareVersions 对**非法 semver 宽容解析**:
12
+ * `1.0.0-01`(数字标识符前导零)、`1.0.0-`(空 pre)在语义上非法,旧实现却当成合法版本走
13
+ * 数值比较,得到 `compareVersions('1.0.0-','1.0.0') === 0` 这种"非法输入等于合法版本"的结论。
14
+ * 本实现改为:**解析失败即回退字符串比较**(结果确定、可预期,且绝不把非法输入当合法)。
15
+ * 主题命中沿用旧实现修过的规则:短 token(≤3)只做精确匹配——旧的"反向子串"
16
+ * (token.includes(topic))会让每个 2–3 字母主题命中一大堆无关条目。
17
+ */
18
+ /**
19
+ * 把查询切成小写 token(字母数字 + CJK 连续段)。
20
+ *
21
+ * 标点/空白都当分隔符:查询是自然语言,不该要求用户记住连字符位置。
22
+ *
23
+ * @param query - 用户/模型输入。
24
+ * @returns 小写 token 列表;空查询返回空数组。
25
+ */
26
+ export function tokenize(query) {
27
+ return query
28
+ .toLowerCase()
29
+ .split(/[^a-z0-9\u4e00-\u9fa5]+/)
30
+ .filter((token) => token.length > 0);
31
+ }
32
+ /**
33
+ * 一个条目对一组 token 的加权分。
34
+ *
35
+ * 权重:名称(display name 或 owner/repo,命中一次只计一次 3 分)/ 主题 2 分 / 描述 1 分,
36
+ * 逐 token 累加。名称与 repo 合并计分是有意的:owner 段(如 `termanli`)也是有效信号,
37
+ * 但同一个 token 同时命中两者不该被算成 6 分(那会让"名字里出现两次"压过真正的相关性)。
38
+ *
39
+ * @param item - 市场条目。
40
+ * @param tokens - {@link tokenize} 的结果。
41
+ * @returns 分数;0 表示不相关。
42
+ */
43
+ export function scoreItem(item, tokens) {
44
+ const name = item.name.toLowerCase();
45
+ const repo = item.repo.toLowerCase();
46
+ const description = item.description.toLowerCase();
47
+ const topics = item.topics.map((topic) => topic.toLowerCase());
48
+ let score = 0;
49
+ for (const token of tokens) {
50
+ if (name.includes(token) || repo.includes(token))
51
+ score += 3;
52
+ if (token.length <= 3
53
+ ? topics.includes(token)
54
+ : topics.some((topic) => topic.includes(token)))
55
+ score += 2;
56
+ if (description.includes(token))
57
+ score += 1;
58
+ }
59
+ return score;
60
+ }
61
+ /** 星数排序键:未知按 0 计("无星"与"未知"在这里是同一个展示位置)。 */
62
+ function starsOf(item) {
63
+ return item.stars ?? 0;
64
+ }
65
+ /** 名称 → repo 的两级升序比较,保证任何排序都是确定的全序。 */
66
+ function compareNames(left, right) {
67
+ if (left.name !== right.name)
68
+ return left.name < right.name ? -1 : 1;
69
+ if (left.repo !== right.repo)
70
+ return left.repo < right.repo ? -1 : 1;
71
+ return 0;
72
+ }
73
+ /**
74
+ * plugin_search 的排序:分数降序 → 星数降序 → 名称升序。
75
+ *
76
+ * 空查询返回**星数最高的前 limit 条**(旧行为保留:模型常问"有什么好用的插件")。
77
+ * 星数相同时按名称升序而不是保持输入顺序:输入顺序来自上游索引,可能与调用方无关,
78
+ * 显式 tie-break 让同样的输入永远给出同样的答案。
79
+ *
80
+ * @param items - 市场条目。
81
+ * @param query - 查询串。
82
+ * @param limit - 返回条数上限(负数/0 视为 0)。
83
+ * @returns 排好序的前 limit 条(不修改入参)。
84
+ */
85
+ export function findPluginMatches(items, query, limit) {
86
+ const size = Math.max(0, Math.trunc(limit));
87
+ if (size === 0)
88
+ return [];
89
+ const tokens = tokenize(query);
90
+ if (tokens.length === 0) {
91
+ return [...items]
92
+ .sort((left, right) => starsOf(right) - starsOf(left) || compareNames(left, right))
93
+ .slice(0, size);
94
+ }
95
+ const scored = [];
96
+ for (const item of items) {
97
+ const score = scoreItem(item, tokens);
98
+ if (score > 0)
99
+ scored.push({ item, score });
100
+ }
101
+ scored.sort((left, right) => right.score - left.score || starsOf(right.item) - starsOf(left.item) || compareNames(left.item, right.item));
102
+ return scored.slice(0, size).map((entry) => entry.item);
103
+ }
104
+ /**
105
+ * 严格解析(v1.2.3-rc.1 的宽松处只有"允许省略 minor/patch"和"允许 v 前缀")。
106
+ *
107
+ * @param value - 版本串。
108
+ * @returns 解析结果;不符合 semver 时 null(调用方回退字符串比较)。
109
+ */
110
+ function parseVersion(value) {
111
+ const text = value.trim().replace(/^v/i, '');
112
+ const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
113
+ if (match === null)
114
+ return null;
115
+ const core = [match[1], match[2] ?? '0', match[3] ?? '0'];
116
+ for (const part of core) {
117
+ // 前导零在 semver 里非法("0" 本身合法)。
118
+ if (part.length > 1 && part.startsWith('0'))
119
+ return null;
120
+ }
121
+ let pre = null;
122
+ if (match[4] !== undefined) {
123
+ pre = match[4].split('.');
124
+ for (const identifier of pre) {
125
+ if (identifier.length === 0)
126
+ return null;
127
+ if (!/^[0-9A-Za-z-]+$/.test(identifier))
128
+ return null;
129
+ if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0'))
130
+ return null;
131
+ }
132
+ }
133
+ return { core, pre };
134
+ }
135
+ /**
136
+ * 数字标识符比较(逐位精确,不经过 Number)。
137
+ *
138
+ * 输入已保证无前导零,所以"位数多的更大,位数相同按字典序"就是数值序;
139
+ * 这个做法同时避开了超长数字标识符(如 20 位 build 号)被 Number 截断精度的问题。
140
+ */
141
+ function compareNumericIdentifier(left, right) {
142
+ if (left.length !== right.length)
143
+ return left.length < right.length ? -1 : 1;
144
+ if (left === right)
145
+ return 0;
146
+ return left < right ? -1 : 1;
147
+ }
148
+ /** 版本串的字典序回退(确定、可预期,绝不返回 NaN)。 */
149
+ function compareStrings(left, right) {
150
+ return left === right ? 0 : left < right ? -1 : 1;
151
+ }
152
+ /**
153
+ * 轻量 semver 比较。
154
+ *
155
+ * 语义(semver §11):`1.2.3-rc.1 < 1.2.3`;`rc.10 > rc.9`;`1.0` / `1` 视作 `1.0.0`;
156
+ * 预发布标识符逐个比较,**数字标识符的优先级低于字母数字**,共享前缀相同时字段少的更小
157
+ * (alpha < alpha.1);build metadata 不参与优先级。
158
+ *
159
+ * 任一侧非法(`1.0.0-01`、`1.0.0-`、`abc`)时**回退原始串比较**:
160
+ * 非法输入不能被当成合法版本参与数值比较(旧审计 m-1)。
161
+ *
162
+ * @param left - 版本串。
163
+ * @param right - 版本串。
164
+ * @returns -1 / 0 / 1。
165
+ */
166
+ export function compareVersions(left, right) {
167
+ const a = parseVersion(left);
168
+ const b = parseVersion(right);
169
+ if (a === null || b === null)
170
+ return compareStrings(left, right);
171
+ for (let index = 0; index < 3; index += 1) {
172
+ const order = compareNumericIdentifier(a.core[index], b.core[index]);
173
+ if (order !== 0)
174
+ return order;
175
+ }
176
+ if (a.pre === null && b.pre === null)
177
+ return 0;
178
+ if (a.pre === null)
179
+ return 1;
180
+ if (b.pre === null)
181
+ return -1;
182
+ const length = Math.max(a.pre.length, b.pre.length);
183
+ for (let index = 0; index < length; index += 1) {
184
+ const x = a.pre[index];
185
+ const y = b.pre[index];
186
+ if (x === undefined)
187
+ return -1;
188
+ if (y === undefined)
189
+ return 1;
190
+ if (x === y)
191
+ continue;
192
+ const xNumeric = /^\d+$/.test(x);
193
+ const yNumeric = /^\d+$/.test(y);
194
+ if (xNumeric && yNumeric)
195
+ return compareNumericIdentifier(x, y);
196
+ if (xNumeric)
197
+ return -1;
198
+ if (yNumeric)
199
+ return 1;
200
+ return x < y ? -1 : 1;
201
+ }
202
+ return 0;
203
+ }
package/dist/net.d.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * net.ts — 市场索引的出网层:超时、代理(HTTP_PROXY/HTTPS_PROXY/NO_PROXY)与可注入的抓取器。
3
+ *
4
+ * 归属:A 类·重写(旧 src/net.ts 仅作意图参考,未复制代码)。
5
+ * 旧实现参考:dsh-web-plugin-manager/src/net.ts(理解意图用,未复制代码)——它解决了两个
6
+ * 真实问题:Node 全局 fetch 不认识 undici 的 dispatcher(要带代理就必须直接调 undici),
7
+ * 以及"只设超时会覆盖调用方传入的 signal"导致取消请求失效。
8
+ * 官方复用:无。官方 host 面不提供通用出网抓取(ctx.* 里没有这类能力),社区索引只能自己直连;
9
+ * 本模块只依赖 package.json 已声明的 undici。
10
+ * 前提检查:旧实现有三个隐含前提,重推后都改了:
11
+ * 1) 超时写死 15s —— 本插件把它做成**用户配置**(settings.marketplace.timeoutMs),故改为入参;
12
+ * 2) 代理 agent 缓存曾经无上限(旧代码后补 MAX_AGENTS,说明当初确实泄漏)——这里从一开始就有
13
+ * 上界,并提供显式关闭(测试与插件卸载用);
14
+ * 3) NO_PROXY 的匹配规则(通配 / 后缀 / 端口 / IPv6 字面量)从未被单测覆盖——抽成纯函数
15
+ * noProxyMatches(),由 tests/marketplace.test.mjs 直接断言。
16
+ *
17
+ * 本模块是**唯一**允许 import undici 的地方;其余模块只依赖这里导出的 `Fetcher` 契约,
18
+ * 因此整条市场管道可以在无网络、无代理的环境里用假 fetcher 完整驱动(tests 就是这么做的)。
19
+ */
20
+ import { ProxyAgent } from 'undici';
21
+ /** 默认请求超时;调用方通常传 settings.marketplace.timeoutMs。 */
22
+ export declare const DEFAULT_TIMEOUT_MS = 15000;
23
+ /** 缓存的代理 agent 上界(代理环境变量变化很少,越界时关闭最旧的一个)。 */
24
+ export declare const MAX_PROXY_AGENTS = 8;
25
+ /**
26
+ * 响应侧的最小结构契约。
27
+ *
28
+ * 不直接用 undici 的 Response 类型当签名:测试里的假 fetcher 只需要实现这几个方法,
29
+ * 而结构式契约把"我们真正用到的东西"写清楚(也避免把 undici 类型扩散到别的模块)。
30
+ */
31
+ export interface HttpResponseLike {
32
+ readonly ok: boolean;
33
+ readonly status: number;
34
+ arrayBuffer(): Promise<ArrayBuffer>;
35
+ text(): Promise<string>;
36
+ }
37
+ /** 一次抓取的入参。 */
38
+ export interface FetchOptions {
39
+ /** 单次请求超时(毫秒)。 */
40
+ readonly timeoutMs?: number;
41
+ readonly headers?: Readonly<Record<string, string>>;
42
+ /** 调用方的取消信号;与超时信号**合并**,不互相覆盖。 */
43
+ readonly signal?: AbortSignal;
44
+ }
45
+ /**
46
+ * 抓取器签名:市场管道唯一的外部依赖。
47
+ *
48
+ * 默认实现是本模块的 {@link fetchWithProxy};测试可注入假实现,
49
+ * 于是"五级索引兜底链 + 磁盘缓存回退"能在无网络条件下逐跳断言。
50
+ */
51
+ export type Fetcher = (url: string, options: FetchOptions) => Promise<HttpResponseLike>;
52
+ /**
53
+ * NO_PROXY 是否覆盖该主机(纯函数,便于单测)。
54
+ *
55
+ * 规则与 curl/undici 生态一致:
56
+ * - `*` 匹配一切;
57
+ * - 逗号分隔,逐项去空白,空项忽略;
58
+ * - 每一项可带端口(`host:port`),端口被忽略(我们只按主机名判断);
59
+ * - IPv6 字面量写作 `[::1]:port`,取方括号内内容;
60
+ * - 支持前导点(`.example.com`)与裸域名的后缀匹配,以及完全相等匹配。
61
+ *
62
+ * 注意:不做通配符(`*.example.com` 里的 `*`)展开——NO_PROXY 的通行约定里
63
+ * 只有单独的 `*` 是通配;把它当通配前缀会让 `*.corp` 静默变成后缀匹配,
64
+ * 看起来"能用",实际覆盖范围与用户预期不同。这里选择只认精确后缀,宁可少放过。
65
+ *
66
+ * @param hostname - 目标主机名(`new URL(url).hostname`,IPv6 自带方括号)。
67
+ * @param raw - NO_PROXY 原始值;undefined 表示未设置。
68
+ * @returns 命中即 true(该请求绕过代理)。
69
+ */
70
+ export declare function noProxyMatches(hostname: string, raw: string | undefined): boolean;
71
+ /**
72
+ * 该 URL 应使用的代理地址,null 表示直连。
73
+ *
74
+ * 大小写两种环境变量名都认(`HTTPS_PROXY` 优先于 `https_proxy`),与旧实现一致;
75
+ * ALL_PROXY 故意不支持——本插件的配置面只承诺这两个(见 settings.ts)。
76
+ *
77
+ * @param url - 目标 URL。
78
+ * @param env - 环境变量来源,默认 process.env(测试可注入)。
79
+ * @returns 代理 URL 原文;URL 非法、无代理或 NO_PROXY 命中时返回 null。
80
+ */
81
+ export declare function proxyUrlFor(url: string, env?: Readonly<Record<string, string | undefined>>): string | null;
82
+ /**
83
+ * 取(或建)一个代理 agent。
84
+ *
85
+ * 上界 {@link MAX_PROXY_AGENTS}:越界时关闭并丢弃最旧的一个。旧实现没有上界,
86
+ * 是后来补的——这里把它当成初始约束而不是补丁。
87
+ *
88
+ * @param proxyUrl - 代理地址(来自 {@link proxyUrlFor})。
89
+ * @returns 该代理对应的 agent。
90
+ */
91
+ export declare function proxyAgentFor(proxyUrl: string): ProxyAgent;
92
+ /**
93
+ * 关闭并清空所有缓存的代理 agent(测试收尾 / 插件卸载)。
94
+ *
95
+ * 不关会留下占着 socket 的 agent:Cordis 热重载时旧实例的 agent 不会被 GC 回收。
96
+ */
97
+ export declare function closeProxyAgents(): void;
98
+ /**
99
+ * 带超时与代理的抓取。
100
+ *
101
+ * 超时通过 `AbortSignal.timeout` 实现,并与调用方 signal **合并**(`AbortSignal.any`):
102
+ * 旧实现用赋值覆盖,导致调用方一旦传了 signal,超时保护就完全消失。
103
+ *
104
+ * @param url - 目标 URL。
105
+ * @param options - 超时 / 头部 / 取消信号。
106
+ * @returns 响应(结构式契约)。
107
+ */
108
+ export declare function fetchWithProxy(url: string, options?: FetchOptions): Promise<HttpResponseLike>;
package/dist/net.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * net.ts — 市场索引的出网层:超时、代理(HTTP_PROXY/HTTPS_PROXY/NO_PROXY)与可注入的抓取器。
3
+ *
4
+ * 归属:A 类·重写(旧 src/net.ts 仅作意图参考,未复制代码)。
5
+ * 旧实现参考:dsh-web-plugin-manager/src/net.ts(理解意图用,未复制代码)——它解决了两个
6
+ * 真实问题:Node 全局 fetch 不认识 undici 的 dispatcher(要带代理就必须直接调 undici),
7
+ * 以及"只设超时会覆盖调用方传入的 signal"导致取消请求失效。
8
+ * 官方复用:无。官方 host 面不提供通用出网抓取(ctx.* 里没有这类能力),社区索引只能自己直连;
9
+ * 本模块只依赖 package.json 已声明的 undici。
10
+ * 前提检查:旧实现有三个隐含前提,重推后都改了:
11
+ * 1) 超时写死 15s —— 本插件把它做成**用户配置**(settings.marketplace.timeoutMs),故改为入参;
12
+ * 2) 代理 agent 缓存曾经无上限(旧代码后补 MAX_AGENTS,说明当初确实泄漏)——这里从一开始就有
13
+ * 上界,并提供显式关闭(测试与插件卸载用);
14
+ * 3) NO_PROXY 的匹配规则(通配 / 后缀 / 端口 / IPv6 字面量)从未被单测覆盖——抽成纯函数
15
+ * noProxyMatches(),由 tests/marketplace.test.mjs 直接断言。
16
+ *
17
+ * 本模块是**唯一**允许 import undici 的地方;其余模块只依赖这里导出的 `Fetcher` 契约,
18
+ * 因此整条市场管道可以在无网络、无代理的环境里用假 fetcher 完整驱动(tests 就是这么做的)。
19
+ */
20
+ import { ProxyAgent, fetch as undiciFetch } from 'undici';
21
+ /** 默认请求超时;调用方通常传 settings.marketplace.timeoutMs。 */
22
+ export const DEFAULT_TIMEOUT_MS = 15_000;
23
+ /** 缓存的代理 agent 上界(代理环境变量变化很少,越界时关闭最旧的一个)。 */
24
+ export const MAX_PROXY_AGENTS = 8;
25
+ /**
26
+ * NO_PROXY 是否覆盖该主机(纯函数,便于单测)。
27
+ *
28
+ * 规则与 curl/undici 生态一致:
29
+ * - `*` 匹配一切;
30
+ * - 逗号分隔,逐项去空白,空项忽略;
31
+ * - 每一项可带端口(`host:port`),端口被忽略(我们只按主机名判断);
32
+ * - IPv6 字面量写作 `[::1]:port`,取方括号内内容;
33
+ * - 支持前导点(`.example.com`)与裸域名的后缀匹配,以及完全相等匹配。
34
+ *
35
+ * 注意:不做通配符(`*.example.com` 里的 `*`)展开——NO_PROXY 的通行约定里
36
+ * 只有单独的 `*` 是通配;把它当通配前缀会让 `*.corp` 静默变成后缀匹配,
37
+ * 看起来"能用",实际覆盖范围与用户预期不同。这里选择只认精确后缀,宁可少放过。
38
+ *
39
+ * @param hostname - 目标主机名(`new URL(url).hostname`,IPv6 自带方括号)。
40
+ * @param raw - NO_PROXY 原始值;undefined 表示未设置。
41
+ * @returns 命中即 true(该请求绕过代理)。
42
+ */
43
+ export function noProxyMatches(hostname, raw) {
44
+ if (raw === undefined || raw.length === 0)
45
+ return false;
46
+ // 两侧都剥掉 IPv6 的方括号:`new URL('http://[::1]/').hostname` 带括号,而 NO_PROXY 里
47
+ // 通常不写;只剥一侧会让 IPv6 的 NO_PROXY 配置静默失效。
48
+ const host = stripBrackets(hostname.trim().toLowerCase());
49
+ if (host.length === 0)
50
+ return false;
51
+ for (const entry of raw.split(',')) {
52
+ const token = entry.trim().toLowerCase();
53
+ if (token.length === 0)
54
+ continue;
55
+ if (token === '*')
56
+ return true;
57
+ const bare = stripBrackets(token.startsWith('[')
58
+ ? token.slice(1, token.indexOf(']') === -1 ? undefined : token.indexOf(']'))
59
+ : token.split(':')[0]);
60
+ const suffix = bare.startsWith('.') ? bare.slice(1) : bare;
61
+ if (suffix.length === 0)
62
+ continue;
63
+ if (host === suffix || host.endsWith('.' + suffix))
64
+ return true;
65
+ }
66
+ return false;
67
+ }
68
+ /** 剥掉 IPv6 字面量的方括号(`[::1]` → `::1`;非方括号输入原样返回)。 */
69
+ function stripBrackets(value) {
70
+ return value.startsWith('[') && value.endsWith(']') ? value.slice(1, -1) : value;
71
+ }
72
+ /**
73
+ * 该 URL 应使用的代理地址,null 表示直连。
74
+ *
75
+ * 大小写两种环境变量名都认(`HTTPS_PROXY` 优先于 `https_proxy`),与旧实现一致;
76
+ * ALL_PROXY 故意不支持——本插件的配置面只承诺这两个(见 settings.ts)。
77
+ *
78
+ * @param url - 目标 URL。
79
+ * @param env - 环境变量来源,默认 process.env(测试可注入)。
80
+ * @returns 代理 URL 原文;URL 非法、无代理或 NO_PROXY 命中时返回 null。
81
+ */
82
+ export function proxyUrlFor(url, env = process.env) {
83
+ let parsed;
84
+ try {
85
+ parsed = new URL(url);
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ const secure = parsed.protocol === 'https:';
91
+ const raw = secure
92
+ ? env['HTTPS_PROXY'] ?? env['https_proxy']
93
+ : env['HTTP_PROXY'] ?? env['http_proxy'];
94
+ if (raw === undefined || raw.trim().length === 0)
95
+ return null;
96
+ if (noProxyMatches(parsed.hostname, env['NO_PROXY'] ?? env['no_proxy']))
97
+ return null;
98
+ return raw;
99
+ }
100
+ /** 缓存的代理 agent:key 是代理 URL。 */
101
+ const agents = new Map();
102
+ /**
103
+ * 取(或建)一个代理 agent。
104
+ *
105
+ * 上界 {@link MAX_PROXY_AGENTS}:越界时关闭并丢弃最旧的一个。旧实现没有上界,
106
+ * 是后来补的——这里把它当成初始约束而不是补丁。
107
+ *
108
+ * @param proxyUrl - 代理地址(来自 {@link proxyUrlFor})。
109
+ * @returns 该代理对应的 agent。
110
+ */
111
+ export function proxyAgentFor(proxyUrl) {
112
+ let agent = agents.get(proxyUrl);
113
+ if (agent === undefined) {
114
+ agent = new ProxyAgent(proxyUrl);
115
+ agents.set(proxyUrl, agent);
116
+ }
117
+ while (agents.size > MAX_PROXY_AGENTS) {
118
+ const oldest = agents.keys().next().value;
119
+ if (oldest === undefined)
120
+ break;
121
+ if (oldest === proxyUrl)
122
+ break; // 极端情况下别把刚建的关掉
123
+ agents.get(oldest)?.close();
124
+ agents.delete(oldest);
125
+ }
126
+ return agent;
127
+ }
128
+ /**
129
+ * 关闭并清空所有缓存的代理 agent(测试收尾 / 插件卸载)。
130
+ *
131
+ * 不关会留下占着 socket 的 agent:Cordis 热重载时旧实例的 agent 不会被 GC 回收。
132
+ */
133
+ export function closeProxyAgents() {
134
+ for (const agent of agents.values())
135
+ agent.close();
136
+ agents.clear();
137
+ }
138
+ /**
139
+ * 带超时与代理的抓取。
140
+ *
141
+ * 超时通过 `AbortSignal.timeout` 实现,并与调用方 signal **合并**(`AbortSignal.any`):
142
+ * 旧实现用赋值覆盖,导致调用方一旦传了 signal,超时保护就完全消失。
143
+ *
144
+ * @param url - 目标 URL。
145
+ * @param options - 超时 / 头部 / 取消信号。
146
+ * @returns 响应(结构式契约)。
147
+ */
148
+ export async function fetchWithProxy(url, options = {}) {
149
+ const timeout = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
150
+ const timeoutSignal = AbortSignal.timeout(timeout);
151
+ const signal = options.signal === undefined
152
+ ? timeoutSignal
153
+ : AbortSignal.any([options.signal, timeoutSignal]);
154
+ const proxy = proxyUrlFor(url);
155
+ const response = await undiciFetch(url, {
156
+ method: 'GET',
157
+ headers: options.headers === undefined ? undefined : { ...options.headers },
158
+ signal,
159
+ redirect: 'follow',
160
+ ...(proxy === null ? {} : { dispatcher: proxyAgentFor(proxy) }),
161
+ });
162
+ return response;
163
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 官方适配层:本插件与 DSH 官方能力之间的**唯一**接口。
3
+ *
4
+ * 归属:A 类·重写(新代码;旧仓库没有这一层,它直接自建了 REST + patch 写入)。
5
+ * 官方复用:pluginManager 服务(当前环境写操作)、host-plugin-inventory
6
+ * (运行时事实)、app-boot 的 profile 读取与 operations 的 pnpm 通道。
7
+ * 前提检查:旧仓库假设"官方只有只读清单,所以必须自建写路径"。0.1.6 之后
8
+ * 该前提消失——官方提供了完整的当前环境写面。本层的职责就是让其余模块
9
+ * **不必知道**官方长什么样,同时把"官方不可用"如实降级而不是崩溃。
10
+ *
11
+ * 三条硬约束(写在类型与运行时里,不靠约定):
12
+ * 1. 当前环境的写操作只走官方 —— 本模块不实现任何文件写入。
13
+ * 2. 服务名绝不用 'pluginManager' —— 官方已占用,同名注册会让整个
14
+ * profile 起不来(旧仓库实测踩过)。
15
+ * 3. 任何官方能力缺失都降级为"能力不可用 + 原因",绝不静默假装成功。
16
+ */
17
+ import type { Context } from '@deepseek-ai/cordis';
18
+ import type { BundleInfo, ChangeResult, PluginEntryId, PluginInfo, PluginInventorySnapshot, PluginSpecInspection } from './types.ts';
19
+ /**
20
+ * 官方能力的可用性快照。
21
+ *
22
+ * 设计意图:把"官方在不在、给不给这个能力"变成**可展示的事实**,而不是
23
+ * 让调用方在一堆 try/catch 里猜。诊断页会把 unavailable 的原因直接呈现给
24
+ * 用户("这个环境没有装配官方插件管理器,以下检查已跳过"),而不是把
25
+ * 缺失伪装成健康。
26
+ */
27
+ export interface OfficialCapabilities {
28
+ /** 当前环境是否由 dsh 以 profile 方式启动(决定 profileContext 是否存在)。 */
29
+ readonly profileBacked: boolean;
30
+ /** 官方 pluginManager 服务是否可用(当前环境的管理能力)。 */
31
+ readonly manager: boolean;
32
+ /** 官方 pluginInventory 是否可用(运行时事实)。 */
33
+ readonly inventory: boolean;
34
+ /** 当前环境名;未知时为 null。 */
35
+ readonly environmentName: string | null;
36
+ /** 缺失能力的说明,直接面向用户展示。 */
37
+ readonly missing: readonly string[];
38
+ }
39
+ /**
40
+ * 官方能力的探针。
41
+ *
42
+ * 为什么用 `ctx.get(name)` 而不是声明式 `inject`:
43
+ * 官方 pluginManager 行在 base bundle 里带
44
+ * `disabled: !!js "!ctx.get('profileContext')"`,即**没有 profile 时它不装配**。
45
+ * 若我们声明式 inject 它,本插件会永远停在 PENDING 而不做任何事——用户看到
46
+ * 一个"装了但没反应"的插件,且没有任何可读的错误。探针 + 降级让插件在
47
+ * 任何宿主上都能加载并如实说明自己不能做什么。
48
+ *
49
+ * @param ctx - 本插件的 host 上下文。
50
+ * @returns 当前可用能力;缺失原因在 `missing` 里逐条列出。
51
+ */
52
+ export declare function probeOfficialCapabilities(ctx: Context): OfficialCapabilities;
53
+ /**
54
+ * 官方 pluginManager 服务的**结构式**视图。
55
+ *
56
+ * 类型面保留官方管理的完整可读面;**运行时校验**只覆盖我们真正调用的方法
57
+ * (见 REQUIRED_MANAGER_METHODS)。两者刻意分开:
58
+ * · listPlugins / listBundles 在本仓库 0 处调用(2026-09-19 核过),所以不进必需清单——
59
+ * 官方改它们时不该让我们报"能力不可用"(那是误报);
60
+ * · 但它们留在类型面里,谁以后要用就有现成的类型,也不必现在顺手收紧类型面。
61
+ *
62
+ * 不 import 官方类:官方包是 peer,小版本之间可能有增减;结构式引用让"官方少了一个
63
+ * 方法"变成一次运行时可读的降级,而不是调用点的 TypeError。
64
+ */
65
+ interface OfficialManagerLike {
66
+ listPlugins(): Promise<PluginInfo[]>;
67
+ listBundles(): Promise<BundleInfo[]>;
68
+ inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection>;
69
+ setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult>;
70
+ setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>;
71
+ installBundle(spec: string, options?: {
72
+ enabled?: boolean;
73
+ requestId?: string;
74
+ approvedBuilds?: readonly string[];
75
+ }): Promise<ChangeResult>;
76
+ removeBundle(name: string): Promise<ChangeResult>;
77
+ }
78
+ /** 官方能力调用失败的统一错误。 */
79
+ export declare class OfficialUnavailableError extends Error {
80
+ readonly capability: string;
81
+ /**
82
+ * @param capability - 缺失的能力名(用于 UI 分组)。
83
+ * @param reason - 面向用户的原因说明。
84
+ */
85
+ constructor(capability: string, reason: string);
86
+ }
87
+ /**
88
+ * 取官方 pluginManager,缺失时抛出可读错误。
89
+ *
90
+ * 每个调用点都必须经过它——这样"官方不可用"永远以一个**具名错误**出现,
91
+ * 而不是 `undefined.listBundles is not a function`。
92
+ *
93
+ * 两层校验:服务存在(ctx.get)与**我们用到的方法存在**。第二层是 2026-09-19 补的:
94
+ * 官方改/删一个 Remote 方法时,用户原本看到调用点的 TypeError(读不懂);现在得到的是
95
+ * "缺哪个方法"+"所以这项能力不可用"。
96
+ *
97
+ * @param ctx - 本插件的 host 上下文。
98
+ * @returns 官方管理器(结构式视图)。
99
+ * @throws {OfficialUnavailableError} 官方管理器未装配,或缺少我们调用到的方法时。
100
+ */
101
+ export declare function requireManager(ctx: Context): OfficialManagerLike;
102
+ /**
103
+ * 读运行时事实。
104
+ *
105
+ * 优先走官方 `readPluginInventory`(它对 Loader 的投影有自己的一致性保证),
106
+ * 拿不到时退回直接读 `ctx.loader.entries()`——用户明确要求"最大化利用能力,
107
+ * 但同时保证不出错",所以两条路都要有,且**降级是显式的**。
108
+ *
109
+ * 返回的 `source` 让诊断页能如实标注数据来源:官方投影口径与直接读 Loader
110
+ * 口径在字段上一致,但前者会跳过 group 行,后者不会。
111
+ *
112
+ * @param ctx - 本插件的 host 上下文。
113
+ * @returns 运行时条目与数据来源;两者都不可用时 `entries` 为空且给出原因。
114
+ */
115
+ export declare function readRuntimeInventory(ctx: Context): Promise<{
116
+ readonly entries: PluginInventorySnapshot['entries'];
117
+ readonly agentPresets: PluginInventorySnapshot['agentPresets'];
118
+ readonly source: 'official' | 'loader' | 'unavailable';
119
+ readonly reason?: string;
120
+ }>;
121
+ /**
122
+ * Cordis FiberState 到官方相位标签的映射。
123
+ *
124
+ * 数值取自 `@deepseek-ai/cordis` 的 `FiberState` 枚举(PENDING=0 …
125
+ * UNLOADING=5)。旧仓库自己定义了一套映射且与本表不一致,是实际缺陷;
126
+ * 这里逐项对齐官方 `plugin-inventory` 的 `FIBER_PHASE`。
127
+ *
128
+ * @param state - fiber.state 的原始数值;undefined 表示无存活 fiber。
129
+ * @returns 官方相位标签,或 null。
130
+ */
131
+ export declare function fiberPhaseOf(state: unknown): PluginInventorySnapshot['entries'][number]['fiberPhase'];
132
+ /**
133
+ * 官方能力缺失时是否应该继续。
134
+ *
135
+ * 只读检查(诊断、盘点)在能力缺失时**继续**,但把缺失记进报告的 `skipped`;
136
+ * 写操作必须**失败**,因为静默跳过写会让用户以为改了而实际没改。
137
+ *
138
+ * @param capabilities - 探针结果。
139
+ * @returns 可执行的只读检查项与不可执行的原因。
140
+ */
141
+ export declare function readOnlyAvailability(capabilities: OfficialCapabilities): {
142
+ readonly canReadRuntime: boolean;
143
+ readonly reason?: string;
144
+ };
145
+ export {};