dsh-rss 0.4.3 → 0.5.1

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.
@@ -0,0 +1,52 @@
1
+ /** Read the enabled Windows manual proxy; never modify OS or VPN settings. */
2
+ import { execFile } from 'node:child_process';
3
+ export function parseWindowsProxy(output) {
4
+ const enabled = /^\s*ProxyEnable\s+REG_DWORD\s+(\S+)\s*$/mi.exec(output);
5
+ if (enabled === null || Number(enabled[1]) !== 1)
6
+ return undefined;
7
+ const server = /^\s*ProxyServer\s+REG_SZ\s+(.+)$/mi.exec(output)?.[1].trim();
8
+ if (!server)
9
+ return undefined;
10
+ return { server, bypass: /^\s*ProxyOverride\s+REG_SZ\s+(.+)$/mi.exec(output)?.[1].trim() ?? '' };
11
+ }
12
+ export function selectWindowsProxy(proxy, url) {
13
+ if (proxy === undefined)
14
+ return '';
15
+ const hostname = url.hostname.toLowerCase();
16
+ for (const pattern of proxy.bypass.split(';').map(value => value.trim().toLowerCase()).filter(Boolean)) {
17
+ if (pattern === '<local>' && !hostname.includes('.'))
18
+ return '';
19
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
20
+ if (new RegExp('^' + escaped + '$', 'i').test(hostname))
21
+ return '';
22
+ }
23
+ const entries = proxy.server.split(';').map(value => value.trim()).filter(Boolean);
24
+ const protocol = url.protocol.slice(0, -1);
25
+ const selected = entries.some(value => value.includes('='))
26
+ ? entries.find(value => value.startsWith(protocol + '='))?.slice(protocol.length + 1)
27
+ : entries[0];
28
+ if (!selected)
29
+ return '';
30
+ try {
31
+ const parsed = new URL(selected.includes('://') ? selected : 'http://' + selected);
32
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
33
+ return '';
34
+ return parsed.href;
35
+ }
36
+ catch {
37
+ return '';
38
+ }
39
+ }
40
+ export async function readWindowsProxy(signal) {
41
+ signal.throwIfAborted();
42
+ if (process.platform !== 'win32')
43
+ return undefined;
44
+ return await new Promise((resolveProxy, reject) => {
45
+ execFile('reg.exe', ['query', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings'], { encoding: 'utf8', windowsHide: true, timeout: 1500, maxBuffer: 128 * 1024, signal }, (error, stdout) => {
46
+ if (signal.aborted)
47
+ reject(signal.reason);
48
+ else
49
+ resolveProxy(error === null ? parseWindowsProxy(stdout) : undefined);
50
+ });
51
+ });
52
+ }
package/lib/tools.js CHANGED
@@ -5,10 +5,11 @@
5
5
  * @module dsh-rss/tools
6
6
  */
7
7
  import { writeFile } from 'node:fs/promises';
8
- import { addFeed, findFeedsByName, parseFeedsYaml, removeFeed, sameFeedUrl, serializeFeeds } from './feeds.js';
8
+ import { addFeed, findFeedsByName, removeFeed, sameFeedUrl } from './feeds.js';
9
9
  import { buildOpml, importOpmlFeeds, parseOpml } from './opml.js';
10
10
  import { parseFeed } from './parser.js';
11
- import { assertHttpUrl, fetchFeedXml } from './network.js';
11
+ import { assertHttpUrl } from './network.js';
12
+ import { subscriptionSnapshot, mutateSubscriptions, readRssFeed } from './subscriptions.js';
12
13
  import { resolveOpmlOutputPath } from './workspace.js';
13
14
  export { isBlockedNetworkAddress } from './network.js';
14
15
  const TIMEOUT_MS = 30000;
@@ -405,19 +406,8 @@ function renderHealth(_args, value) {
405
406
  */
406
407
  export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
407
408
  const cfg = config;
408
- const getFeeds = () => {
409
- const value = settingsScope.get();
410
- const yamlText = value !== null && typeof value === 'object' && typeof value.feedsYaml === 'string' ? value.feedsYaml : '';
411
- return parseFeedsYaml(yamlText);
412
- };
413
- const persistFeeds = async (feeds) => {
414
- try {
415
- await settingsScope.update({ feedsYaml: serializeFeeds(feeds) });
416
- }
417
- catch (error) {
418
- throw new Error('写入订阅配置失败(可能已被其他会话修改,请重试):' + (error instanceof Error ? error.message : String(error)));
419
- }
420
- };
409
+ const getFeeds = () => subscriptionSnapshot(settingsScope).feeds;
410
+ const readFeed = (url, exec) => readRssFeed(settingsScope, url, cfg, exec, fetchImpl, lookupImpl);
421
411
  const rssList = {
422
412
  name: 'rss_list',
423
413
  description: '列出已订阅的 RSS/Atom 源(存在 settings 的 dsh-rss.feedsYaml 里,可用 rss_add / rss_remove 管理)。返回每个订阅的 url、name、category 与总数。',
@@ -450,12 +440,12 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
450
440
  assertHttpUrl(url);
451
441
  const name = optionalString(args, 'name') ?? '';
452
442
  const category = optionalString(args, 'category') ?? '';
453
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
454
- const parsed = parseFeed(xml, finalUrl);
443
+ const { parsed } = await readFeed(url, exec);
455
444
  const effectiveName = name !== '' ? name : parsed.feed.title;
456
- const { feeds, added, existed } = addFeed(getFeeds(), url, effectiveName, category);
457
- await persistFeeds(feeds);
458
- return { added, existed, count: feeds.length, feedTitle: parsed.feed.title, feedType: parsed.feed.feedType };
445
+ return mutateSubscriptions(settingsScope, current => {
446
+ const { feeds, added, existed } = addFeed(current, url, effectiveName, category);
447
+ return { feeds, value: { added, existed, count: feeds.length, feedTitle: parsed.feed.title, feedType: parsed.feed.feedType } };
448
+ });
459
449
  },
460
450
  timeoutMs: cfg.timeoutMs,
461
451
  };
@@ -472,9 +462,10 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
472
462
  },
473
463
  async execute(rawArgs) {
474
464
  const args = asRecord(rawArgs);
475
- const { feeds, removed } = removeFeed(getFeeds(), optionalString(args, 'url'), optionalString(args, 'name'));
476
- await persistFeeds(feeds);
477
- return { removed, count: feeds.length };
465
+ return mutateSubscriptions(settingsScope, current => {
466
+ const { feeds, removed } = removeFeed(current, optionalString(args, 'url'), optionalString(args, 'name'));
467
+ return { feeds, value: { removed, count: feeds.length } };
468
+ });
478
469
  },
479
470
  timeoutMs: TIMEOUT_MS,
480
471
  };
@@ -523,8 +514,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
523
514
  assertHttpUrl(url);
524
515
  const limit = clampedInteger(args, 'limit', 20, 1, 100);
525
516
  const incremental = args.incremental === true;
526
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
527
- const parsed = parseFeed(xml, finalUrl);
517
+ const { parsed, finalUrl } = await readFeed(url, exec);
528
518
  if (!incremental) {
529
519
  const entries = parsed.entries.slice(0, limit);
530
520
  return { url: finalUrl, feed: parsed.feed, entries, truncated: parsed.entries.length > limit };
@@ -552,8 +542,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
552
542
  const args = asRecord(rawArgs);
553
543
  const url = requiredString(args, 'url', '订阅源地址');
554
544
  assertHttpUrl(url);
555
- const { xml, finalUrl } = await fetchFeedXml(url, cfg, exec, fetchImpl, lookupImpl);
556
- const parsed = parseFeed(xml, finalUrl);
545
+ const { parsed, finalUrl } = await readFeed(url, exec);
557
546
  return {
558
547
  ok: true,
559
548
  url: finalUrl,
@@ -605,16 +594,14 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
605
594
  const args = asRecord(rawArgs);
606
595
  const opmlText = requiredString(args, 'opml', 'OPML 文本');
607
596
  const document = parseOpml(opmlText);
608
- const outcome = importOpmlFeeds(getFeeds(), document);
609
- await persistFeeds(outcome.feeds);
610
- return {
611
- addedCount: outcome.addedCount,
612
- existedCount: outcome.existedCount,
613
- skippedCount: outcome.skippedCount,
614
- totalCount: outcome.feeds.length,
615
- added: outcome.added,
616
- skipped: outcome.skipped,
617
- };
597
+ return mutateSubscriptions(settingsScope, current => {
598
+ const outcome = importOpmlFeeds(current, document);
599
+ return { feeds: outcome.feeds, value: {
600
+ addedCount: outcome.addedCount, existedCount: outcome.existedCount,
601
+ skippedCount: outcome.skippedCount, totalCount: outcome.feeds.length,
602
+ added: outcome.added, skipped: outcome.skipped,
603
+ } };
604
+ });
618
605
  },
619
606
  timeoutMs: TIMEOUT_MS,
620
607
  };
@@ -656,8 +643,7 @@ export function buildRssTools(config, settingsScope, fetchImpl, lookupImpl) {
656
643
  if (hits.length >= limit)
657
644
  break;
658
645
  try {
659
- const { xml, finalUrl } = await fetchFeedXml(feed.url, cfg, exec, fetchImpl, lookupImpl);
660
- const parsed = parseFeed(xml, finalUrl);
646
+ const { parsed } = await readFeed(feed.url, exec);
661
647
  for (const entry of parsed.entries) {
662
648
  const rec = asRecord(entry);
663
649
  const at = Date.parse(typeof rec.pubDate === 'string' ? rec.pubDate : '');
package/lib/web.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ /** Authenticated, local settings route; no separate copy of subscription data. */
2
+ import type { IncomingMessage, ServerResponse } from 'node:http';
3
+ import type { RssSettingsScope } from './tools.js';
4
+ import type { ResolvedRssConfig } from './config.js';
5
+ import { type FetchLike, type DnsLookupLike } from './network.js';
6
+ export declare const RSS_SETTINGS_ROUTE = "/_dsh/dsh-rss/subscriptions";
7
+ export declare function createRssSettingsBackend(config: ResolvedRssConfig, scope: RssSettingsScope, options?: {
8
+ writable?: () => boolean;
9
+ fetchImpl?: FetchLike;
10
+ lookupImpl?: DnsLookupLike;
11
+ }): {
12
+ snapshot: () => {
13
+ revision: string;
14
+ writable: boolean;
15
+ feeds: {
16
+ id: string;
17
+ check: import("./subscriptions.js").FeedCheck | null;
18
+ url: string;
19
+ name: string;
20
+ category: string;
21
+ }[];
22
+ };
23
+ handle(req: IncomingMessage, res: ServerResponse): Promise<void>;
24
+ };
25
+ export declare function installRssSettingsWeb(ctx: {
26
+ inject?: Function;
27
+ }, backend: ReturnType<typeof createRssSettingsBackend>): void;
package/lib/web.js ADDED
@@ -0,0 +1,217 @@
1
+ import { sameFeedUrl } from './feeds.js';
2
+ import { assertHttpUrl } from './network.js';
3
+ import { buildOpml, parseOpml, importOpmlFeeds } from './opml.js';
4
+ import { feedId, subscriptionSnapshot, mutateSubscriptions, recentFeedChecks, readRssFeed, SubscriptionError } from './subscriptions.js';
5
+ export const RSS_SETTINGS_ROUTE = '/_dsh/dsh-rss/subscriptions';
6
+ // JSON quoting can double the OPML file size; limit the actual text separately.
7
+ const MAX_OPML = 1024 * 1024;
8
+ const MAX_BODY = MAX_OPML * 2 + 16384;
9
+ const loopback = (host) => ['127.0.0.1', 'localhost', '::1', '[::1]', '::ffff:127.0.0.1'].includes(host.toLowerCase());
10
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
11
+ const text = (args, key) => typeof args[key] === 'string' ? args[key].trim() : '';
12
+ function requestGuard(req) {
13
+ const host = req.headers.host;
14
+ if (!host || !loopback(req.socket.remoteAddress ?? ''))
15
+ throw new SubscriptionError('设置面板仅允许本机访问。', 403, 'local_only');
16
+ let target;
17
+ try {
18
+ target = new URL('http://' + host);
19
+ }
20
+ catch {
21
+ throw new SubscriptionError('无效的请求地址。', 403);
22
+ }
23
+ if (!loopback(target.hostname) || target.username || target.password || target.pathname !== '/' || target.search || target.hash) {
24
+ throw new SubscriptionError('设置面板仅允许本机访问。', 403, 'local_only');
25
+ }
26
+ const protocol = req.socket.encrypted ? 'https:' : 'http:';
27
+ target.protocol = protocol;
28
+ const origin = req.headers.origin;
29
+ if ((req.method === 'POST' && !origin) || (origin !== undefined && origin !== target.origin)) {
30
+ throw new SubscriptionError('请从当前 DSH 设置页面操作。', 403, 'origin');
31
+ }
32
+ if (req.method === 'POST' && !/^application\/json(?:\s*;|$)/i.test(req.headers['content-type'] ?? '')) {
33
+ throw new SubscriptionError('请求必须使用 JSON。', 415);
34
+ }
35
+ }
36
+ function readBody(req, signal) {
37
+ return new Promise((resolve, reject) => {
38
+ let length = 0;
39
+ const chunks = [];
40
+ const cleanup = () => { req.off('data', data); req.off('end', end); req.off('error', error); signal.removeEventListener('abort', abort); };
41
+ const fail = (reason) => { cleanup(); reject(reason); };
42
+ const data = (chunk) => {
43
+ length += chunk.length;
44
+ if (length > MAX_BODY) {
45
+ chunks.length = 0;
46
+ fail(new SubscriptionError('内容超过 1 MiB,请拆分 OPML 文件后重试。', 413));
47
+ req.resume();
48
+ }
49
+ else
50
+ chunks.push(chunk);
51
+ };
52
+ const error = (reason) => fail(reason);
53
+ const abort = () => fail(signal.reason);
54
+ const end = () => {
55
+ cleanup();
56
+ try {
57
+ const value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
58
+ if (!value || typeof value !== 'object' || Array.isArray(value))
59
+ throw new Error();
60
+ resolve(value);
61
+ }
62
+ catch {
63
+ reject(new SubscriptionError('请求内容不是有效的 JSON。'));
64
+ }
65
+ };
66
+ if (signal.aborted) {
67
+ abort();
68
+ return;
69
+ }
70
+ req.on('data', data);
71
+ req.on('end', end);
72
+ req.on('error', error);
73
+ signal.addEventListener('abort', abort, { once: true });
74
+ });
75
+ }
76
+ export function createRssSettingsBackend(config, scope, options = {}) {
77
+ const writable = () => options.writable?.() !== false;
78
+ const snapshot = () => {
79
+ const state = subscriptionSnapshot(scope);
80
+ const checks = recentFeedChecks(scope);
81
+ return { revision: state.revision, writable: writable(), feeds: state.feeds.map(feed => ({ ...feed, id: feedId(feed.url), check: checks.get(feed.url) ?? null })) };
82
+ };
83
+ const find = (feeds, id) => {
84
+ const feed = feeds.find(feed => feedId(feed.url) === id);
85
+ if (!feed)
86
+ throw new SubscriptionError('这个订阅已被移除,请刷新列表。', 409, 'conflict');
87
+ return feed;
88
+ };
89
+ const check = (url, signal) => {
90
+ assertHttpUrl(url);
91
+ return readRssFeed(scope, url, config, { signal }, options.fetchImpl, options.lookupImpl);
92
+ };
93
+ const preview = (args) => {
94
+ const opml = text(args, 'opml');
95
+ if (Buffer.byteLength(opml, 'utf8') > MAX_OPML)
96
+ throw new SubscriptionError('OPML 内容超过 1 MiB,请拆分后导入。', 413, 'bodyLarge');
97
+ const state = subscriptionSnapshot(scope);
98
+ const updateExisting = args.updateExisting === true;
99
+ const outcome = importOpmlFeeds(state.feeds, parseOpml(opml), updateExisting);
100
+ // Never return credentials embedded in rejected URLs.
101
+ const skipped = outcome.skipped.map(item => ({ ...item, url: item.url.replace(/(https?:\/\/)[^/@\s]+@/gi, '$1***@') }));
102
+ return { ...outcome, feeds: undefined, skipped, revision: state.revision,
103
+ token: feedId(JSON.stringify([state.revision, opml, updateExisting])) };
104
+ };
105
+ async function dispatch(args, signal) {
106
+ const action = text(args, 'action');
107
+ if (action === 'export')
108
+ return { opml: buildOpml(subscriptionSnapshot(scope).feeds) };
109
+ if (action === 'preview')
110
+ return { preview: preview(args) };
111
+ if (action === 'check') {
112
+ const url = text(args, 'id') ? find(subscriptionSnapshot(scope).feeds, text(args, 'id')).url : text(args, 'url');
113
+ const { parsed } = await check(url, signal);
114
+ return { check: { ok: true, title: parsed.feed.title, entryCount: parsed.entries.length } };
115
+ }
116
+ if (!['add', 'edit', 'remove', 'import'].includes(action))
117
+ throw new SubscriptionError('未知的操作。');
118
+ if (!writable())
119
+ throw new SubscriptionError('当前配置是只读的,无法保存订阅。', 403, 'readonly');
120
+ const revision = text(args, 'revision');
121
+ if (!revision)
122
+ throw new SubscriptionError('请先刷新订阅列表。', 409, 'conflict');
123
+ let result;
124
+ if (action === 'import') {
125
+ const candidate = preview(args);
126
+ if (candidate.revision !== revision || candidate.token !== text(args, 'token')) {
127
+ throw new SubscriptionError('订阅列表或导入内容已经变化,请重新预览;你的输入已保留。', 409, 'conflict');
128
+ }
129
+ result = await mutateSubscriptions(scope, feeds => {
130
+ signal.throwIfAborted();
131
+ const outcome = importOpmlFeeds(feeds, parseOpml(text(args, 'opml')), args.updateExisting === true);
132
+ return { feeds: outcome.feeds, value: { addedCount: outcome.addedCount, existedCount: outcome.existedCount, skippedCount: outcome.skippedCount } };
133
+ }, revision);
134
+ }
135
+ else if (action === 'remove') {
136
+ result = await mutateSubscriptions(scope, feeds => {
137
+ signal.throwIfAborted();
138
+ const target = find(feeds, text(args, 'id'));
139
+ return { feeds: feeds.filter(feed => feed !== target), value: { removed: target.name || target.url } };
140
+ }, revision);
141
+ }
142
+ else {
143
+ const url = text(args, 'url');
144
+ assertHttpUrl(url);
145
+ const current = subscriptionSnapshot(scope);
146
+ if (current.revision !== revision)
147
+ throw new SubscriptionError('订阅列表已经变化,请刷新后再保存;你的输入已保留。', 409, 'conflict');
148
+ const old = action === 'edit' ? find(current.feeds, text(args, 'id')) : undefined;
149
+ let defaultName = '';
150
+ if (!old || !sameFeedUrl(old.url, url))
151
+ defaultName = (await check(url, signal)).parsed.feed.title;
152
+ result = await mutateSubscriptions(scope, feeds => {
153
+ signal.throwIfAborted();
154
+ const previous = action === 'edit' ? find(feeds, text(args, 'id')) : undefined;
155
+ if (feeds.some(feed => feed !== previous && sameFeedUrl(feed.url, url))) {
156
+ throw new SubscriptionError('这个地址已经订阅,请编辑现有订阅。', 409, 'duplicate');
157
+ }
158
+ const next = { url, name: text(args, 'name') || defaultName, category: text(args, 'category') };
159
+ return { feeds: previous ? feeds.map(feed => feed === previous ? next : feed) : [...feeds, next], value: { saved: next.name || next.url } };
160
+ }, revision);
161
+ }
162
+ return { result };
163
+ }
164
+ return {
165
+ snapshot,
166
+ async handle(req, res) {
167
+ const controller = new AbortController();
168
+ const abort = () => { if (!res.writableFinished)
169
+ controller.abort(new Error('请求已取消')); };
170
+ req.on('aborted', abort);
171
+ res.on('close', abort);
172
+ const send = (status, value) => {
173
+ if (res.destroyed)
174
+ return;
175
+ res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' });
176
+ res.end(JSON.stringify(value));
177
+ };
178
+ try {
179
+ requestGuard(req);
180
+ if (req.method === 'GET')
181
+ send(200, { snapshot: snapshot() });
182
+ else if (req.method === 'POST') {
183
+ const result = await dispatch(await readBody(req, controller.signal), controller.signal);
184
+ send(200, { ...result, snapshot: snapshot() });
185
+ }
186
+ else {
187
+ res.setHeader('Allow', 'GET, POST');
188
+ send(405, { error: '仅支持 GET 和 POST。', code: 'method' });
189
+ }
190
+ }
191
+ catch (error) {
192
+ if (!controller.signal.aborted) {
193
+ let state;
194
+ try {
195
+ state = snapshot();
196
+ }
197
+ catch { /* Malformed saved YAML must not hide the original error. */ }
198
+ send(error instanceof SubscriptionError ? error.status : 400, {
199
+ error: error instanceof Error ? error.message : String(error), code: error instanceof SubscriptionError ? error.code : 'invalid', snapshot: state,
200
+ });
201
+ }
202
+ }
203
+ finally {
204
+ req.off('aborted', abort);
205
+ res.off('close', abort);
206
+ }
207
+ },
208
+ };
209
+ }
210
+ export function installRssSettingsWeb(ctx, backend) {
211
+ if (typeof ctx.inject !== 'function')
212
+ return;
213
+ ctx.inject(['webServer'], (webCtx) => webCtx.effect(() => webCtx.webServer.register({
214
+ kind: 'exact', path: RSS_SETTINGS_ROUTE,
215
+ handler: (req, res) => backend.handle(req, res),
216
+ }), 'dsh-rss: settings route'));
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rss",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "DSH RSS 插件:订阅源管理、OPML 导入导出、抓取解析 RSS/Atom 并保留正文。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -10,6 +10,7 @@
10
10
  "types": "./lib/index.d.ts",
11
11
  "default": "./lib/index.js"
12
12
  },
13
+ "./client": "./lib/client.js",
13
14
  "./cordis.patch.yml": "./cordis.patch.yml",
14
15
  "./package.json": "./package.json"
15
16
  },
@@ -26,6 +27,12 @@
26
27
  "prepublishOnly": "pnpm run build"
27
28
  },
28
29
  "dsh": {
30
+ "client": {
31
+ "inject": [
32
+ "@deepseek-ai/dsh-client-ui-settings"
33
+ ],
34
+ "platform": "web"
35
+ },
29
36
  "bundle": {
30
37
  "patch": "./cordis.patch.yml"
31
38
  }
@@ -43,11 +50,11 @@
43
50
  "node": "^22.19.0 || >=24.0.0"
44
51
  },
45
52
  "dependencies": {
53
+ "@deepseek-ai/schemastery": "^3.18.3",
46
54
  "fast-xml-parser": "^5.0.0",
47
55
  "schemastery": "^3.18.0",
48
- "undici": "^8.10.0",
49
- "yaml": "^2.9.0",
50
- "@deepseek-ai/schemastery": "^3.18.3"
56
+ "undici": "^8.11.2",
57
+ "yaml": "^2.9.0"
51
58
  },
52
59
  "devDependencies": {
53
60
  "@types/node": "^24.0.0",