@asiainfo-sdd/multica-cli 0.2.1 → 0.3.2

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/README.md CHANGED
@@ -1,98 +1,129 @@
1
- # @asiainfo-sdd/multica-cli — aido(daemon 客户端)安装帮助包
2
-
3
- 通过 npm 安装数字工厂 aido 命令(daemon 客户端二进制,含主机线能力)。
4
- 安装后对外命令名是 **`aido`**(`multica` 作为兼容别名同时可用,内部等价)。
5
- **本包负责安装二进制与部署级配置**:npm install 时按平台自动下载、sha256 校验、落位,
6
- 并把 `server_url` / `app_url` 合并写入 `~/.multica/config.json`(只填空,不覆盖已有值);
7
- **不写 token、不登录、不启动/停止 daemon**——个人凭据与 daemon 生命周期始终由用户掌控。
8
-
9
- ## 部署级配置的取值优先级
10
-
11
- ```
12
- MULTICA_SERVER_URL / MULTICA_APP_URL 环境变量(安装时)
13
- > manifest.json 顶层 server_url / app_url(运维改下载源即可,无需重发 npm 包)
14
- > 包内 lib/dist-config.json(发布时固化)
15
- ```
16
-
17
- 当前测试环境默认值(lib/dist-config.json):`server_url = http://10.19.198.57:8080`(multica 测试环境)、`app_url = http://10.19.198.57:8080`(daemon login 浏览器 OAuth 流的基准地址)。
18
-
19
- 用户 `~/.multica/config.json` 中已有的值(如指向测试服)**一律不覆盖**,仅提示。
20
-
21
- ## 适用人群与前置
22
-
23
- - 内网办公电脑/服务器上的开发者,机器可访问下载源(默认 Nexus raw,可覆盖)
24
- - 已安装 Node.js ≥ 18 与 npm
25
- - 支持平台:darwin / linux / windows × amd64 / arm64
26
-
27
- ## 安装
28
-
29
- ```bash
30
- # 全局安装(推荐:npm 全局 bin 目录已在 PATH)
31
- npm i -g @asiainfo-sdd/multica-cli
32
-
33
- # 或项目内安装(用 npx 调用)
34
- npm i @asiainfo-sdd/multica-cli
35
- npx aido version
36
- ```
37
-
38
- 安装完成后按提示自行完成剩余步骤(若下载源已带 server_url 则只需两步):
39
-
40
- ```bash
41
- aido config set server_url https://<服务器地址> # 仅当安装时未自动写入
42
- aido login --token mul_<个人令牌> # 令牌在 Web 端“设置→令牌”生成
43
- aido daemon start # 需本机已装至少一个 agent CLI(claude/codex/opencode…)
44
- ```
45
-
46
- 日常管理:`aido daemon status` / `aido daemon logs` / `aido daemon stop`。
47
-
48
- > `multica xxx` 与 `aido xxx` 完全等价(同一二进制、同一配置),文档统一用 `aido`。
49
-
50
- ## 环境变量
51
-
52
- | 变量 | 作用 |
53
- | --- | --- |
54
- | `MULTICA_DOWNLOAD_BASE` | 覆盖二进制下载源基址(默认内网 Nexus raw 地址;源根需有 `manifest.json` 与 `v<版本>/` 目录) |
55
- | `MULTICA_SERVER_URL` / `MULTICA_APP_URL` | 安装时写入 `~/.multica/config.json` 的部署级配置(优先级高于 manifest 与包内置;不覆盖已有值) |
56
- | `MULTICA_SKIP_BINARY_INSTALL=1` | 跳过 postinstall 下载(`--ignore-scripts` 环境或自备二进制时用) |
57
- | `http_proxy` / `https_proxy` / `NO_PROXY` | 走内网代理时识别(Node 自带 fetch 不认这些变量,本包已自实现) |
58
-
59
- ## 故障恢复
60
-
61
- | 症状 | 处理 |
62
- | --- | --- |
63
- | 安装时看不到“上手”提示 | npm ≥ 7 默认隐藏 postinstall 输出(脚本失败时才显示);加 `--foreground-scripts` 参数即可看到,或安装后执行 `aido version` 确认可用 |
64
- | 执行 `aido` 提示二进制未下载 | `npm rebuild @asiainfo-sdd/multica-cli`(常见于 `--ignore-scripts` 安装或当时下载源不可达) |
65
- | 提示“aido 不是命令” | 重新打开终端;或用 `npx aido ...` |
66
- | 升级时提示文件被占用 | daemon 正在运行:先 `aido daemon stop`,再 `npm i -g @asiainfo-sdd/multica-cli@<版本>` |
67
- | 下载源不可达 | 确认 `MULTICA_DOWNLOAD_BASE`;检查代理变量 |
68
-
69
- ## 升级
70
-
71
- ```bash
72
- npm i -g @asiainfo-sdd/multica-cli@<新版本>
73
- ```
74
-
75
- npm 包版本与二进制版本一一对应;postinstall 检测到版本变化会重新下载。
76
- daemon 的 auto-reload 默认开启:二进制替换后运行中的 daemon 会自动重启到新版本。
77
-
78
- ## 版本发布(维护者)
79
-
80
- 1. multica 仓 `server/extensions/agentfoundry/build/build.sh cli-all <版本>`:
81
- 6 平台 CLI 产物 + `manifest.json` 落 `dist/cli/`
82
- 2. 如需随包分发部署级配置,在 `manifest.json` 顶层加 `server_url` / `app_url`
83
- 3. 将 `dist/cli/` 整目录同步到 HTTP 静态源根(Nexus raw `repository/multica-cli/`)
84
- 4. 同步更新本仓 `package.json` 的 `version` 与 `lib/manifest.fallback.json`(兜底清单)、`lib/dist-config.json`(包内置默认配置),`npm publish`
85
-
86
- ## FAQ
87
-
88
- **Q: 为什么不把二进制打进 npm 包?**
89
- 每个平台 17-68MB,6 平台约 200MB;HTTP 运行时下载让 npm 包保持 KB 级,且安装哪台机器才拉哪个平台。
90
-
91
- **Q: postinstall 下载失败了怎么办?**
92
- 安装不会失败到阻断——失败时打印修复指引,之后 `npm rebuild @asiainfo-sdd/multica-cli` 重试。
93
-
94
- **Q: aido 和 multica 什么关系?**
95
- `aido` 是对外品牌命令,`multica` 是兼容别名;两者指向同一二进制与配置,输出完全一致。对用户统一推荐 `aido`。
96
-
97
- **Q: daemon 会自动升级吗?**
98
- self-host 场景下 daemon 的 auto-update 默认关闭;升级由你通过 npm 主动控制。
1
+ # @asiainfo-sdd/multica-cli — multica(daemon 客户端)安装帮助包
2
+
3
+ 通过 npm 安装数字工厂 multica 命令(daemon 客户端二进制,含主机线能力)。
4
+ **本包负责安装二进制与部署级配置**:npm install 时按平台自动下载、sha256 校验、落位,
5
+ 并把 `server_url` / `app_url` 合并写入 `~/.multica/config.json`(只填空,不覆盖已有值);
6
+ **不写 token、不登录、不启动/停止 daemon**——个人凭据与 daemon 生命周期始终由用户掌控。
7
+
8
+ ## 部署级配置的取值优先级
9
+
10
+ ```
11
+ MULTICA_SERVER_URL / MULTICA_APP_URL 环境变量(安装时)
12
+ > manifest.json 顶层 server_url / app_url(运维改下载源即可,无需重发 npm 包)
13
+ > 包内 lib/dist-config.json(发布时按 profile 固化)
14
+ ```
15
+
16
+ 二进制下载源基址同样三级:`MULTICA_DOWNLOAD_BASE` 环境变量 > manifest 对应环境的 `lib/dist-config.json` 的 `download_base` > 包内置默认。
17
+
18
+ 用户 `~/.multica/config.json` 中已有的值**一律不覆盖**,仅提示。
19
+
20
+ ## 适用人群与前置
21
+
22
+ - 内网办公电脑/服务器上的开发者,机器可访问下载源(可覆盖)
23
+ - 已安装 Node.js ≥ 18 与 npm
24
+ - 支持平台:darwin / linux / windows × amd64 / arm64
25
+
26
+ ## 安装
27
+
28
+ ```bash
29
+ # 全局安装(推荐:npm 全局 bin 目录已在 PATH)
30
+ npm i -g @asiainfo-sdd/multica-cli
31
+
32
+ # 或项目内安装(用 npx 调用)
33
+ npm i @asiainfo-sdd/multica-cli
34
+ npx multica version
35
+ ```
36
+
37
+ 安装完成后按提示自行完成剩余步骤(若下载源已带 server_url 则只需两步):
38
+
39
+ ```bash
40
+ multica config set server_url https://<服务器地址> # 仅当安装时未自动写入
41
+ multica login --token mul_<个人令牌> # 令牌在 Web 端“设置→令牌”生成
42
+ multica daemon start # 需本机已装至少一个 agent CLI(claude/codex/opencode…)
43
+ ```
44
+
45
+ 日常管理:`multica daemon status` / `multica daemon logs` / `multica daemon stop`。
46
+
47
+ ## 环境变量
48
+
49
+ | 变量 | 作用 |
50
+ | --- | --- |
51
+ | `MULTICA_DOWNLOAD_BASE` | 覆盖二进制下载源基址(源根需有 `manifest.json` 与 `v<版本>/` 目录) |
52
+ | `MULTICA_SERVER_URL` / `MULTICA_APP_URL` | 安装时写入 `~/.multica/config.json` 的部署级配置(优先级高于 manifest 与包内置;不覆盖已有值) |
53
+ | `MULTICA_SKIP_BINARY_INSTALL=1` | 跳过 postinstall 下载(`--ignore-scripts` 环境或自备二进制时用) |
54
+ | `http_proxy` / `https_proxy` / `NO_PROXY` | 走内网代理时识别(Node 自带 fetch 不认这些变量,本包已自实现) |
55
+
56
+ ## 故障恢复
57
+
58
+ | 症状 | 处理 |
59
+ | --- | --- |
60
+ | 安装时看不到“上手”提示 | npm ≥ 7 默认隐藏 postinstall 输出(脚本失败时才显示);加 `--foreground-scripts` 参数即可看到,或安装后执行 `multica version` 确认可用 |
61
+ | 执行 `multica` 提示二进制未下载 | `npm rebuild @asiainfo-sdd/multica-cli`(常见于 `--ignore-scripts` 安装或当时下载源不可达) |
62
+ | 提示“multica 不是命令” | 重新打开终端;或用 `npx multica ...` |
63
+ | 升级时提示文件被占用 | daemon 正在运行:先 `multica daemon stop`,再 `npm i -g @asiainfo-sdd/multica-cli@<版本>` |
64
+ | 下载源不可达 | 确认 `MULTICA_DOWNLOAD_BASE`;检查代理变量 |
65
+
66
+ ## 升级
67
+
68
+ ```bash
69
+ npm i -g @asiainfo-sdd/multica-cli@<新版本>
70
+ ```
71
+
72
+ npm 包版本与二进制版本一一对应;postinstall 检测到版本变化会重新下载。
73
+ daemon 的 auto-reload 默认开启:二进制替换后运行中的 daemon 会自动重启到新版本。
74
+
75
+ ## 卸载
76
+
77
+ **用 `multica-uninstall`,不要只用 `npm un -g`**。原因:
78
+
79
+ - npm v7 起不再执行 `preuninstall` 等卸载生命周期脚本,`npm un -g` 只能移除 npm 包,
80
+ 不会停 daemon、不会删 `~/.multica` 配置、也不会删独立安装的 `~/.local/bin/multica`;
81
+ - 同机可能同时存在多个 npm 全局目录(nvm prefix、`%APPDATA%\npm`、历史 prefix),
82
+ 而 `npm root -g` 只报当前一个 —— 会出现“卸了还能用”。
83
+
84
+ `multica-uninstall` 一次覆盖全部安装产物:
85
+
86
+ ```bash
87
+ multica-uninstall # 交互确认后卸载
88
+ multica-uninstall --yes # 非交互(脚本/CI)
89
+ multica-uninstall --keep-config # 保留 ~/.multica(保留登录令牌与配置)
90
+ multica-uninstall --dry-run # 只打印将执行的操作,不改动任何文件
91
+ ```
92
+
93
+ 执行内容依次为:停 daemon → 删 `~/.multica`(除非 `--keep-config`)→ 删独立二进制
94
+ `~/.local/bin/multica[.exe]` → 删 PATH 上的命令入口(npm shim)→ 逐个前缀卸载 npm 全局包。
95
+
96
+ 卸载后请**重开终端**以确保 PATH 刷新;再执行 `multica version` 应提示命令不存在。
97
+
98
+ > 若某 npm 包因文件占用未能删除(Windows 常见),脚本会打印出对应
99
+ > `npm uninstall -g --prefix <prefix> <包名>` 命令,重开终端后手动执行即可。
100
+
101
+ ## 版本发布(维护者)
102
+
103
+ 本包位于 multica 仓 `apps/multica-cli-installer/`(与 `server/` 同仓管理,随主仓 git 提交发布)。
104
+ 同一份源码按 profile 发布两个 npm 包(差异仅包名与内置部署级配置),发布流程:
105
+
106
+ 1. 本仓 `server/extensions/agentfoundry/build/build.sh cli-all <版本>`:
107
+ 6 平台 CLI 产物 + `manifest.json` 落 `server/extensions/agentfoundry/dist/cli/`
108
+ 2. 如需随包分发部署级配置,在 `manifest.json` 顶层加 `server_url` / `app_url`
109
+ 3. 将 `dist/cli/` 整目录同步到目标环境下载源的 HTTP 静态根(MinIO `daemon-download/` 前缀)
110
+ 4. 在 `apps/multica-cli-installer/` 下按 profile 发布:
111
+ - 生产包:`node scripts/publish.mjs prod [版本]`
112
+ - 测试包:`node scripts/publish.mjs test`(连接测试环境,配置见 `publish/profiles.json`)
113
+ - 加 `--dry-run` 只打 tarball 检查,不实际发布
114
+ 5. 版本号在源 `package.json` 上推进(`lib/dist-config.json` 保持生产默认值;测试包的差异由 profile 注入)
115
+
116
+ > 两包 bin 命令名均为 `multica`,同一台机器互斥安装;切换环境用
117
+ > `npm rm -g <A> && npm i -g <B>`,安装时会自动覆盖式更新 `~/.multica/config.json`
118
+ > 的 server_url/app_url 并提示(token 需重新登录生成)。
119
+
120
+ ## FAQ
121
+
122
+ **Q: 为什么不把二进制打进 npm 包?**
123
+ 每个平台 17-68MB,6 平台约 200MB;HTTP 运行时下载让 npm 包保持 KB 级,且安装哪台机器才拉哪个平台。
124
+
125
+ **Q: postinstall 下载失败了怎么办?**
126
+ 安装不会失败到阻断——失败时打印修复指引,之后 `npm rebuild @asiainfo-sdd/multica-cli` 重试。
127
+
128
+ **Q: daemon 会自动升级吗?**
129
+ self-host 场景下 daemon 的 auto-update 默认关闭;升级由你通过 npm 主动控制。
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // multica-uninstall 命令入口:委托 lib/uninstall.js。
5
+ require('../lib/uninstall.js');
package/bin/multica.js CHANGED
@@ -2,25 +2,22 @@
2
2
  'use strict';
3
3
 
4
4
  // bin wrapper:把参数原样转给 vendor/ 下的平台二进制。
5
- // multica 是兼容别名入口(对外品牌命令是 aido,见 bin/aido.js)。
5
+ // 对外唯一命令名是 multica(aido 入口已移除)。
6
6
  // 二进制的子命令(daemon/login/config/...)与参数原样透传。
7
7
 
8
8
  const { spawn } = require('child_process');
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { detectPlatform, vendorBinaryPath } = require('../lib/platform');
12
-
13
- // 对外命令名:默认 aido;用户通过 multica 别名调用时保持 multica 提示。
14
- const invokedAs = path.basename(process.argv[1] || '', '.js').replace(/\.(cmd|ps1)$/, '') || 'aido';
15
- const brand = invokedAs === 'multica' ? 'multica' : 'aido';
12
+ const pkg = require('../package.json');
16
13
 
17
14
  const plat = detectPlatform();
18
15
  const binary =
19
16
  plat && vendorBinaryPath(path.join(__dirname, '..', 'vendor'), plat);
20
17
 
21
18
  if (!binary || !fs.existsSync(binary)) {
22
- console.error(`✗ ${brand} 二进制尚未下载。`);
23
- console.error(' 修复方式:npm rebuild @asiainfo-sdd/multica-cli');
19
+ console.error('✗ multica 二进制尚未下载。');
20
+ console.error(` 修复方式:npm rebuild ${pkg.name}`);
24
21
  console.error(' (或设置 MULTICA_SKIP_BINARY_INSTALL=1 显式跳过后自备二进制)');
25
22
  process.exit(127);
26
23
  }
@@ -40,7 +37,7 @@ process.on('exit', () => {
40
37
  });
41
38
 
42
39
  child.on('error', (err) => {
43
- console.error(`✗ 无法执行 ${brand} 二进制:${err.message}`);
40
+ console.error(`✗ 无法执行 multica 二进制:${err.message}`);
44
41
  process.exit(126);
45
42
  });
46
43
 
@@ -1,4 +1,5 @@
1
1
  {
2
2
  "server_url": "https://aido.asiainfo.com/runtime-svc",
3
- "app_url": "https://aido.asiainfo.com/runtime-svc"
3
+ "app_url": "https://aido.asiainfo.com/runtime-svc",
4
+ "download_base": "https://aido.asiainfo.com/aird-api/business/api/v1/external/daemon-download"
4
5
  }
package/lib/http.js CHANGED
@@ -1,174 +1,174 @@
1
- 'use strict';
2
-
3
- // 零依赖 HTTP 客户端:Node 自带 fetch(undici)不认 http_proxy/https_proxy
4
- // 环境变量,内网代理场景必须自己处理。支持:
5
- // - 直连 http/https
6
- // - http_proxy / https_proxy / HTTP_PROXY / HTTPS_PROXY
7
- // - NO_PROXY(逗号分隔主机名,支持后缀匹配)
8
- // - https over proxy:CONNECT 隧道
9
- // 流式响应,供下载侧边收边算 sha256。
10
-
11
- const http = require('http');
12
- const https = require('https');
13
- const { URL } = require('url');
14
-
15
- const KEEP_ALIVE_AGENT_OPTIONS = { keepAlive: false };
16
-
17
- function proxyFromEnv(targetURL) {
18
- const url = new URL(targetURL);
19
- if (matchesNoProxy(url.hostname)) return null;
20
- const proto = url.protocol === 'https:' ? 'https' : 'http';
21
- const raw =
22
- process.env[`${proto}_proxy`] || process.env[`${proto}_PROXY`] || '';
23
- if (!raw) return null;
24
- try {
25
- const proxy = new URL(raw);
26
- if (!/^https?:$/.test(proxy.protocol)) return null;
27
- return proxy;
28
- } catch {
29
- return null;
30
- }
31
- }
32
-
33
- function matchesNoProxy(hostname) {
34
- const list = (process.env.NO_PROXY || process.env.NO_PROXY === '' ? process.env.NO_PROXY : process.env.no_proxy) || '';
35
- if (!list) return false;
36
- return list
37
- .split(',')
38
- .map((s) => s.trim().toLowerCase())
39
- .filter(Boolean)
40
- .some((entry) => {
41
- if (entry === '*') return true;
42
- if (entry.startsWith('.')) return hostname.endsWith(entry);
43
- return hostname === entry || hostname.endsWith(`.${entry}`);
44
- });
45
- }
46
-
47
- // http 目标 + http 代理:向代理发绝对 URI 的 GET。
48
- // https 目标 + 任意代理:先 CONNECT 隧道再在隧道上发 GET。
49
- function request(targetURL, { headers = {}, timeoutMs = 120000 } = {}) {
50
- return new Promise((resolve, reject) => {
51
- let url;
52
- try {
53
- url = new URL(targetURL);
54
- } catch (err) {
55
- reject(new Error(`invalid URL: ${targetURL}`));
56
- return;
57
- }
58
- const proxy = proxyFromEnv(targetURL);
59
-
60
- const onConnectError = (err) =>
61
- reject(new Error(`proxy connect failed: ${err.message}`));
62
-
63
- if (url.protocol === 'https:' && proxy) {
64
- const proxyPort = Number(proxy.port) || (proxy.protocol === 'https:' ? 443 : 80);
65
- const connectReq = http.request({
66
- host: proxy.hostname,
67
- port: proxyPort,
68
- method: 'CONNECT',
69
- path: `${url.hostname}:${url.port || 443}`,
70
- headers: { Host: `${url.hostname}:${url.port || 443}` },
71
- timeout: timeoutMs,
72
- });
73
- connectReq.on('connect', (res, socket) => {
74
- if (res.statusCode !== 200) {
75
- socket.destroy();
76
- reject(new Error(`proxy CONNECT failed: ${res.statusCode}`));
77
- return;
78
- }
79
- const agent = new https.Agent({ ...KEEP_ALIVE_AGENT_OPTIONS, socket });
80
- const req = https.get(
81
- {
82
- host: url.hostname,
83
- port: url.port || 443,
84
- path: url.pathname + url.search,
85
- headers,
86
- agent,
87
- timeout: timeoutMs,
88
- },
89
- resolve,
90
- );
91
- req.on('timeout', () => {
92
- req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
93
- });
94
- req.on('error', reject);
95
- });
96
- connectReq.on('error', onConnectError);
97
- connectReq.on('timeout', () => {
98
- connectReq.destroy(new Error(`proxy connect timed out after ${timeoutMs}ms`));
99
- });
100
- connectReq.end();
101
- return;
102
- }
103
-
104
- const isHttps = url.protocol === 'https:';
105
- const mod = isHttps ? https : http;
106
- const options = {
107
- host: proxy ? proxy.hostname : url.hostname,
108
- port: proxy
109
- ? Number(proxy.port) || (proxy.protocol === 'https:' ? 443 : 80)
110
- : Number(url.port) || (isHttps ? 443 : 80),
111
- method: 'GET',
112
- path: proxy ? targetURL : url.pathname + url.search,
113
- headers,
114
- timeout: timeoutMs,
115
- };
116
- // https 代理入口(少见于内网)不支持时直接报错,不做二次降级。
117
- if (proxy && proxy.protocol === 'https:') {
118
- reject(new Error('https proxy entry is not supported'));
119
- return;
120
- }
121
- const req = mod.get(options, resolve);
122
- req.on('timeout', () => {
123
- req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
124
- });
125
- req.on('error', reject);
126
- });
127
- }
128
-
129
- function checkStatus(res, what) {
130
- if (res.statusCode < 200 || res.statusCode >= 300) {
131
- res.resume();
132
- throw new Error(`${what} failed: HTTP ${res.statusCode} ${res.statusMessage || ''}`.trim());
133
- }
134
- return res;
135
- }
136
-
137
- async function fetchText(url, { timeoutMs = 10000 } = {}) {
138
- const res = checkStatus(await request(url, { timeoutMs }), `fetch ${url}`);
139
- const chunks = [];
140
- for await (const chunk of res) chunks.push(chunk);
141
- return Buffer.concat(chunks).toString('utf8');
142
- }
143
-
144
- // 流式下载到可写流,返回 { statusCode 后已校验的响应流 }。
145
- // onProgress(bytesDone, totalOrNull) 仅在有内容时回调。
146
- function downloadToStream(url, dest, { timeoutMs = 300000, onProgress } = {}) {
147
- return new Promise(async (resolve, reject) => {
148
- let res;
149
- try {
150
- res = await request(url, { timeoutMs });
151
- checkStatus(res, `download ${url}`);
152
- } catch (err) {
153
- reject(err);
154
- return;
155
- }
156
- const total = Number(res.headers['content-length']) || null;
157
- let done = 0;
158
- let lastTick = 0;
159
- res.on('data', (chunk) => {
160
- done += chunk.length;
161
- const now = Date.now();
162
- if (onProgress && (now - lastTick > 200 || done === total)) {
163
- lastTick = now;
164
- onProgress(done, total);
165
- }
166
- });
167
- res.on('error', reject);
168
- dest.on('error', reject);
169
- res.pipe(dest);
170
- dest.on('finish', () => resolve());
171
- });
172
- }
173
-
174
- module.exports = { request, fetchText, downloadToStream };
1
+ 'use strict';
2
+
3
+ // 零依赖 HTTP 客户端:Node 自带 fetch(undici)不认 http_proxy/https_proxy
4
+ // 环境变量,内网代理场景必须自己处理。支持:
5
+ // - 直连 http/https
6
+ // - http_proxy / https_proxy / HTTP_PROXY / HTTPS_PROXY
7
+ // - NO_PROXY(逗号分隔主机名,支持后缀匹配)
8
+ // - https over proxy:CONNECT 隧道
9
+ // 流式响应,供下载侧边收边算 sha256。
10
+
11
+ const http = require('http');
12
+ const https = require('https');
13
+ const { URL } = require('url');
14
+
15
+ const KEEP_ALIVE_AGENT_OPTIONS = { keepAlive: false };
16
+
17
+ function proxyFromEnv(targetURL) {
18
+ const url = new URL(targetURL);
19
+ if (matchesNoProxy(url.hostname)) return null;
20
+ const proto = url.protocol === 'https:' ? 'https' : 'http';
21
+ const raw =
22
+ process.env[`${proto}_proxy`] || process.env[`${proto}_PROXY`] || '';
23
+ if (!raw) return null;
24
+ try {
25
+ const proxy = new URL(raw);
26
+ if (!/^https?:$/.test(proxy.protocol)) return null;
27
+ return proxy;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function matchesNoProxy(hostname) {
34
+ const list = (process.env.NO_PROXY || process.env.NO_PROXY === '' ? process.env.NO_PROXY : process.env.no_proxy) || '';
35
+ if (!list) return false;
36
+ return list
37
+ .split(',')
38
+ .map((s) => s.trim().toLowerCase())
39
+ .filter(Boolean)
40
+ .some((entry) => {
41
+ if (entry === '*') return true;
42
+ if (entry.startsWith('.')) return hostname.endsWith(entry);
43
+ return hostname === entry || hostname.endsWith(`.${entry}`);
44
+ });
45
+ }
46
+
47
+ // http 目标 + http 代理:向代理发绝对 URI 的 GET。
48
+ // https 目标 + 任意代理:先 CONNECT 隧道再在隧道上发 GET。
49
+ function request(targetURL, { headers = {}, timeoutMs = 120000 } = {}) {
50
+ return new Promise((resolve, reject) => {
51
+ let url;
52
+ try {
53
+ url = new URL(targetURL);
54
+ } catch (err) {
55
+ reject(new Error(`invalid URL: ${targetURL}`));
56
+ return;
57
+ }
58
+ const proxy = proxyFromEnv(targetURL);
59
+
60
+ const onConnectError = (err) =>
61
+ reject(new Error(`proxy connect failed: ${err.message}`));
62
+
63
+ if (url.protocol === 'https:' && proxy) {
64
+ const proxyPort = Number(proxy.port) || (proxy.protocol === 'https:' ? 443 : 80);
65
+ const connectReq = http.request({
66
+ host: proxy.hostname,
67
+ port: proxyPort,
68
+ method: 'CONNECT',
69
+ path: `${url.hostname}:${url.port || 443}`,
70
+ headers: { Host: `${url.hostname}:${url.port || 443}` },
71
+ timeout: timeoutMs,
72
+ });
73
+ connectReq.on('connect', (res, socket) => {
74
+ if (res.statusCode !== 200) {
75
+ socket.destroy();
76
+ reject(new Error(`proxy CONNECT failed: ${res.statusCode}`));
77
+ return;
78
+ }
79
+ const agent = new https.Agent({ ...KEEP_ALIVE_AGENT_OPTIONS, socket });
80
+ const req = https.get(
81
+ {
82
+ host: url.hostname,
83
+ port: url.port || 443,
84
+ path: url.pathname + url.search,
85
+ headers,
86
+ agent,
87
+ timeout: timeoutMs,
88
+ },
89
+ resolve,
90
+ );
91
+ req.on('timeout', () => {
92
+ req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
93
+ });
94
+ req.on('error', reject);
95
+ });
96
+ connectReq.on('error', onConnectError);
97
+ connectReq.on('timeout', () => {
98
+ connectReq.destroy(new Error(`proxy connect timed out after ${timeoutMs}ms`));
99
+ });
100
+ connectReq.end();
101
+ return;
102
+ }
103
+
104
+ const isHttps = url.protocol === 'https:';
105
+ const mod = isHttps ? https : http;
106
+ const options = {
107
+ host: proxy ? proxy.hostname : url.hostname,
108
+ port: proxy
109
+ ? Number(proxy.port) || (proxy.protocol === 'https:' ? 443 : 80)
110
+ : Number(url.port) || (isHttps ? 443 : 80),
111
+ method: 'GET',
112
+ path: proxy ? targetURL : url.pathname + url.search,
113
+ headers,
114
+ timeout: timeoutMs,
115
+ };
116
+ // https 代理入口(少见于内网)不支持时直接报错,不做二次降级。
117
+ if (proxy && proxy.protocol === 'https:') {
118
+ reject(new Error('https proxy entry is not supported'));
119
+ return;
120
+ }
121
+ const req = mod.get(options, resolve);
122
+ req.on('timeout', () => {
123
+ req.destroy(new Error(`request timed out after ${timeoutMs}ms`));
124
+ });
125
+ req.on('error', reject);
126
+ });
127
+ }
128
+
129
+ function checkStatus(res, what) {
130
+ if (res.statusCode < 200 || res.statusCode >= 300) {
131
+ res.resume();
132
+ throw new Error(`${what} failed: HTTP ${res.statusCode} ${res.statusMessage || ''}`.trim());
133
+ }
134
+ return res;
135
+ }
136
+
137
+ async function fetchText(url, { timeoutMs = 10000 } = {}) {
138
+ const res = checkStatus(await request(url, { timeoutMs }), `fetch ${url}`);
139
+ const chunks = [];
140
+ for await (const chunk of res) chunks.push(chunk);
141
+ return Buffer.concat(chunks).toString('utf8');
142
+ }
143
+
144
+ // 流式下载到可写流,返回 { statusCode 后已校验的响应流 }。
145
+ // onProgress(bytesDone, totalOrNull) 仅在有内容时回调。
146
+ function downloadToStream(url, dest, { timeoutMs = 300000, onProgress } = {}) {
147
+ return new Promise(async (resolve, reject) => {
148
+ let res;
149
+ try {
150
+ res = await request(url, { timeoutMs });
151
+ checkStatus(res, `download ${url}`);
152
+ } catch (err) {
153
+ reject(err);
154
+ return;
155
+ }
156
+ const total = Number(res.headers['content-length']) || null;
157
+ let done = 0;
158
+ let lastTick = 0;
159
+ res.on('data', (chunk) => {
160
+ done += chunk.length;
161
+ const now = Date.now();
162
+ if (onProgress && (now - lastTick > 200 || done === total)) {
163
+ lastTick = now;
164
+ onProgress(done, total);
165
+ }
166
+ });
167
+ res.on('error', reject);
168
+ dest.on('error', reject);
169
+ res.pipe(dest);
170
+ dest.on('finish', () => resolve());
171
+ });
172
+ }
173
+
174
+ module.exports = { request, fetchText, downloadToStream };