@having5548/dsh-downloader 0.0.0-stage → 0.5.3

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,143 @@
1
+ /**
2
+ * 把本插件的代理接进 shell 命令。
3
+ *
4
+ * 背景:`git` / `curl` 跑在 shell 子进程里,工具型插件无法在进程外截获它们。
5
+ * 因此"适配"由两半组成:
6
+ * 1. 一段 shell 前缀,把这一个命令的 `HTTP_PROXY` / `HTTPS_PROXY` 指向本插件的
7
+ * 规则内核(内核再按规则决定直连还是走节点),**只影响这一次 shell 调用**;
8
+ * 2. 一条工具守卫,在检测到"从境外下载"的 shell 命令时拦下来,告诉模型改用
9
+ * `dsh_run_proxied` 或 `dsh_download`。
10
+ *
11
+ * 本模块只做纯计算,便于离线测试。
12
+ * @module dsh-downloader/shell-proxy
13
+ */
14
+ import { isPrivateIp } from "../download/util.js";
15
+
16
+ /** 代理相关的环境变量名,大小写都写,兼容 curl / git / npm。 */
17
+ export const PROXY_ENV_NAMES = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
18
+
19
+ /** 看起来像"要从网上取东西"的命令。 */
20
+ const DOWNLOAD_HINT = /\b(git\s+(?:clone|fetch|pull|submodule)|curl|wget|Invoke-WebRequest|iwr|Start-BitsTransfer)\b/i;
21
+
22
+ /** 命令里已经带代理的迹象(避免拦住模型按提示修正后的重试)。 */
23
+ const ALREADY_PROXIED = /(HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|http_proxy|https_proxy|all_proxy|\s-x\s+https?:\/\/|--proxy|http\.proxy|ProxyCommand)/i;
24
+
25
+ /** 提取命令里的 http(s) URL,去重。 */
26
+ export function extractHttpUrls(command) {
27
+ const text = String(command ?? "");
28
+ const found = text.match(/https?:\/\/[^\s"'`;|&)<>]+/gi) ?? [];
29
+ const unique = [];
30
+ for (const raw of found) {
31
+ const cleaned = raw.replace(/[.,;:]+$/, "");
32
+ if (!unique.includes(cleaned)) unique.push(cleaned);
33
+ }
34
+ return unique;
35
+ }
36
+
37
+ /** 主机是否是本机/内网(这类目标永远不该被判成"需要代理")。 */
38
+ function isLocalHost(host) {
39
+ const text = String(host ?? "").toLowerCase().replace(/^\[|\]$/g, "");
40
+ if (text.length === 0) return false;
41
+ if (text === "localhost" || text.endsWith(".localhost") || text.endsWith(".local")) return true;
42
+ return isPrivateIp(text);
43
+ }
44
+
45
+ /**
46
+ * 判断一条 shell 命令是否会从境外下载。
47
+ *
48
+ * 只认「明确带 http(s) URL 的下载类命令」:`git clone/fetch/pull/submodule`、
49
+ * `curl`、`wget`、`Invoke-WebRequest`。SSH 形式(`git@github.com:…`)不走 HTTP,
50
+ * 代理环境变量对它无效,因此不会被判定,也就不会被拦。
51
+ *
52
+ * @param command - 完整的 shell 命令。
53
+ * @param engine - 规则引擎(`{ decide(host) }`),null 时不做判定。
54
+ * @returns `{ isDownload, alreadyProxied, urls, foreign }`
55
+ */
56
+ export function classifyShellCommand(command, engine) {
57
+ const text = String(command ?? "");
58
+ const isDownload = DOWNLOAD_HINT.test(text);
59
+ const alreadyProxied = ALREADY_PROXIED.test(text);
60
+ const urls = extractHttpUrls(text);
61
+ const foreign = [];
62
+ if (isDownload && engine !== null && engine !== undefined && !alreadyProxied) {
63
+ for (const url of urls) {
64
+ let host = "";
65
+ try {
66
+ host = new URL(url).hostname;
67
+ } catch {
68
+ continue;
69
+ }
70
+ if (isLocalHost(host)) continue;
71
+ if (engine.decide(host) === "proxy") foreign.push({ url, host });
72
+ }
73
+ }
74
+ return { isDownload, alreadyProxied, urls, foreign };
75
+ }
76
+
77
+ /** 代理 URL:用户名固定 `dsh`,密码是本进程的令牌(本插件的内核按它鉴权)。 */
78
+ export function buildProxyUrl({ host = "127.0.0.1", port, token }) {
79
+ return `http://dsh:${encodeURIComponent(String(token))}@${host}:${port}`;
80
+ }
81
+
82
+ /** 单个环境变量赋值语句。 */
83
+ function assignment(shell, name, value) {
84
+ if (String(shell).toLowerCase() === "bash") return `export ${name}='${value}';`;
85
+ return `$env:${name}='${value}';`;
86
+ }
87
+
88
+ /**
89
+ * 组装一段 shell 前缀,把这一个命令的代理指向本插件内核。
90
+ *
91
+ * 除了 `*_PROXY`,还会通过 `GIT_CONFIG_COUNT` 给 git 单独喂两条配置:
92
+ * - `http.proxy` —— **git 不会像 curl 那样预发 `Proxy-Authorization`**,把带凭据的
93
+ * 代理写进 `http.proxy` 才让它把凭据交给 libcurl;
94
+ * - `http.proxyAuthMethod=basic` —— 与内核的 407 挑战(Basic)对上。
95
+ *
96
+ * 这两条对非 git 命令完全无副作用(git 之外没人读它们),所以不做命令嗅探。
97
+ *
98
+ * @param shell - `pwsh`(默认)或 `bash`。
99
+ * @param proxyUrl - {@link buildProxyUrl} 的结果。
100
+ * @param noProxy - 绕过列表(回环 + 国内 AI 平台域名)。
101
+ * @returns 可直接拼在原命令前面的字符串。
102
+ */
103
+ export function buildShellPrefix({ shell = "pwsh", proxyUrl, noProxy }) {
104
+ const parts = PROXY_ENV_NAMES.map((name) => assignment(shell, name, proxyUrl));
105
+ parts.push(assignment(shell, "NO_PROXY", noProxy), assignment(shell, "no_proxy", noProxy));
106
+ const gitConfig = [
107
+ ["http.proxy", proxyUrl],
108
+ ["http.proxyAuthMethod", "basic"]
109
+ ];
110
+ parts.push(assignment(shell, "GIT_CONFIG_COUNT", String(gitConfig.length)));
111
+ gitConfig.forEach(([key, value], index) => {
112
+ parts.push(assignment(shell, `GIT_CONFIG_KEY_${index}`, key));
113
+ parts.push(assignment(shell, `GIT_CONFIG_VALUE_${index}`, value));
114
+ });
115
+ return `${parts.join(" ")} `;
116
+ }
117
+
118
+ /** 提示模型该怎么做的中文说明。 */
119
+ export function explainShellRouting({ foreign }) {
120
+ const hosts = [...new Set(foreign.map((item) => item.host))];
121
+ return (
122
+ `这条命令会从境外下载(${hosts.join("、")}),但当前 shell 没有走本插件的代理。` +
123
+ "请改用 dsh_run_proxied 跑这条命令(它会为这一次调用注入代理环境)," +
124
+ "或者如果只是要下一个文件,直接用 dsh_download。"
125
+ );
126
+ }
127
+
128
+ /**
129
+ * 组装委派给 shell 工具的参数字典。
130
+ *
131
+ * shell 工具的 `description` 是**必填**的 —— 漏了它会以
132
+ * 「missing required property "description"」直接失败(实测踩过)。
133
+ * @param options - 包装后的命令、可选描述与工作目录。
134
+ * @returns 可直接作为 `arguments` 传给 shell 工具的字典。
135
+ */
136
+ export function buildShellToolArguments({ command, description, workdir }) {
137
+ const text = typeof description === "string" && description.trim().length > 0
138
+ ? description.trim()
139
+ : `带代理执行:${String(command ?? "").trim().slice(0, 60)}`;
140
+ const payload = { command, description: text };
141
+ if (typeof workdir === "string" && workdir.trim().length > 0) payload.workdir = workdir;
142
+ return payload;
143
+ }
@@ -0,0 +1,126 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Vendored from dsh-clash-proxy 0.2.0 (MIT).
3
+ // Source: H:\mycode\example\dsh-clash\lib\core\subscription.js
4
+ // [dsh-downloader] `fetchSubscription` was rewritten to use this plugin's own
5
+ // node:http client instead of undici's ProxyAgent, so the package has no
6
+ // undici dependency. `parseSubscription` additionally accepts share-link lists.
7
+ // ---------------------------------------------------------------------------
8
+ import yaml from "js-yaml";
9
+ import { parseShareLinkList } from "./share-links.js";
10
+ import { httpFlow, parseProxyUrl, readBody } from "../download/http.js";
11
+
12
+ /**
13
+ * Clash subscription fetching and parsing.
14
+ *
15
+ * Accepts both a full mihomo config (proxies / proxy-groups / rules) and a
16
+ * bare proxy list, plus the base64-wrapped bodies some providers deliver.
17
+ * @module dsh-downloader/subscription
18
+ */
19
+
20
+ /**
21
+ * Fetch the raw subscription body together with the response headers.
22
+ *
23
+ * [dsh-downloader] Runs on the plugin's own HTTP client. `fetchProxyUrl` is
24
+ * optional and only ever an `http(s)://` proxy: a subscription typically has to
25
+ * be reachable *before* any node exists, so a SOCKS-only fetch proxy is refused
26
+ * with an actionable message rather than silently ignored.
27
+ *
28
+ * The headers matter because that is where FlClash gets a profile's name
29
+ * (`Content-Disposition`) and its traffic/expiry (`subscription-userinfo`).
30
+ *
31
+ * @param url - the subscription URL.
32
+ * @param fetchProxyUrl - optional http(s) proxy for reaching the subscription itself.
33
+ * @param timeoutMs - request budget.
34
+ * @returns `{ text, headers, status, url, redirects }`.
35
+ */
36
+ export async function fetchSubscription(url, fetchProxyUrl, timeoutMs = 30000) {
37
+ let proxy = null;
38
+ if (typeof fetchProxyUrl === "string" && fetchProxyUrl.trim().length > 0) {
39
+ const parsed = parseProxyUrl(fetchProxyUrl);
40
+ if (parsed === null) throw new Error(`fetchProxyUrl 不是可用的代理地址:${fetchProxyUrl}`);
41
+ if (parsed.socks === true) throw new Error("fetchProxyUrl 必须是 http:// 或 https:// 代理;在还没有任何节点时无法使用 SOCKS 代理");
42
+ proxy = parsed;
43
+ }
44
+ const result = await httpFlow(url, {
45
+ proxy,
46
+ timeoutMs,
47
+ maxRedirects: 5,
48
+ headers: { accept: "text/plain, application/yaml, */*" }
49
+ });
50
+ if (result.status < 200 || result.status >= 300) {
51
+ try {
52
+ result.res.resume();
53
+ result.abort();
54
+ } catch {
55
+ /* already gone */
56
+ }
57
+ throw new Error(`订阅地址返回 HTTP ${result.status}`);
58
+ }
59
+ const text = await readBody(result, 8 * 1024 * 1024);
60
+ return { text, headers: result.headers ?? {}, status: result.status, url: result.url, redirects: result.redirects };
61
+ }
62
+
63
+ /** True when the body looks like base64 rather than YAML. */
64
+ function looksBase64(text) {
65
+ const head = text.trimStart().slice(0, 64);
66
+ return !/^[a-z_][\w-]*\s*:|\s*-\s/m.test(text) && /^[A-Za-z0-9+/=\r\n]+$/.test(head.replace(/\s+/g, ""));
67
+ }
68
+
69
+ /** Parse one subscription body into proxies, groups, and rules. */
70
+ export function parseSubscription(raw) {
71
+ let document = null;
72
+ const attempts = [raw];
73
+ if (looksBase64(raw)) attempts.unshift(Buffer.from(raw.replace(/\s+/g, ""), "base64").toString("utf8"));
74
+ for (const candidate of attempts) {
75
+ try {
76
+ document = yaml.load(candidate);
77
+ if (document !== null && typeof document === "object") break;
78
+ document = null;
79
+ } catch {
80
+ // Try the next representation.
81
+ }
82
+ }
83
+ if (document === null) {
84
+ // 不是 YAML:很大一部分机场返回的是「一行一个分享链接」(常裹一层 base64)。
85
+ const links = parseShareLinkList(raw);
86
+ if (links !== null) {
87
+ return { proxies: links.proxies, groups: [], rules: [], names: links.names, skipped: links.skipped, kind: "share-links" };
88
+ }
89
+ throw new Error("订阅内容既不是 YAML、不是 base64 包裹的 YAML,也不是分享链接列表");
90
+ }
91
+
92
+ const payload = Array.isArray(document) ? { proxies: document } : document;
93
+ const rawProxies = Array.isArray(payload?.proxies) ? payload.proxies : [];
94
+ const proxies = rawProxies.filter((proxy) => {
95
+ const name = typeof proxy === "string" ? proxy : proxy?.name;
96
+ if (name === "DIRECT" || name === "REJECT" || name === "REJECT-DROP" || name === "PASS" || name === "COMPATIBLE" || name === "GLOBAL") return false;
97
+ if (typeof name !== "string" || name.length === 0) return false;
98
+ return true;
99
+ });
100
+ const groups = Array.isArray(payload?.["proxy-groups"]) ? payload["proxy-groups"] : [];
101
+ const rules = Array.isArray(payload?.rules) ? payload.rules : [];
102
+
103
+ return {
104
+ proxies,
105
+ groups,
106
+ rules,
107
+ names: proxies.map((proxy) => (typeof proxy === "string" ? proxy : String(proxy.name))).filter(Boolean),
108
+ skipped: [],
109
+ kind: "yaml"
110
+ };
111
+ }
112
+
113
+ /** Sanitize group objects: drop group members that do not resolve. */
114
+ export function sanitizeGroups(groups, names) {
115
+ const known = new Set(names);
116
+ const clean = [];
117
+ for (const group of groups) {
118
+ if (typeof group !== "object" || group === null) continue;
119
+ const members = Array.isArray(group.proxies)
120
+ ? group.proxies.filter((member) => known.has(member) || member === "DIRECT" || member === "REJECT")
121
+ : [];
122
+ if (members.length === 0) continue;
123
+ clean.push({ ...group, proxies: members });
124
+ }
125
+ return clean;
126
+ }