@asiainfo-sdd/multica-cli 0.0.0-stage → 0.2.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.
- package/README.md +97 -2
- package/bin/aido.js +52 -0
- package/bin/multica.js +52 -0
- package/lib/dist-config.json +4 -0
- package/lib/http.js +174 -0
- package/lib/install.js +333 -0
- package/lib/platform.js +45 -0
- package/lib/server-config.js +94 -0
- package/package.json +23 -4
package/README.md
CHANGED
|
@@ -1,3 +1,98 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @asiainfo-sdd/multica-cli — aido(daemon 客户端)安装帮助包
|
|
2
2
|
|
|
3
|
-
|
|
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 主动控制。
|
package/bin/aido.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// bin wrapper:把参数原样转给 vendor/ 下的平台二进制。
|
|
5
|
+
// aido 是对外品牌命令(multica 是兼容别名,另一入口 bin/multica.js)。
|
|
6
|
+
// 二进制的子命令(daemon/login/config/...)与参数原样透传。
|
|
7
|
+
|
|
8
|
+
const { spawn } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
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';
|
|
16
|
+
|
|
17
|
+
const plat = detectPlatform();
|
|
18
|
+
const binary =
|
|
19
|
+
plat && vendorBinaryPath(path.join(__dirname, '..', 'vendor'), plat);
|
|
20
|
+
|
|
21
|
+
if (!binary || !fs.existsSync(binary)) {
|
|
22
|
+
console.error(`✗ ${brand} 二进制尚未下载。`);
|
|
23
|
+
console.error(' 修复方式:npm rebuild @asiainfo-sdd/multica-cli');
|
|
24
|
+
console.error(' (或设置 MULTICA_SKIP_BINARY_INSTALL=1 显式跳过后自备二进制)');
|
|
25
|
+
process.exit(127);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const child = spawn(binary, process.argv.slice(2), {
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
env: process.env,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Windows 上 Ctrl-C 走 SIGINT/Break;转发常见终止信号。
|
|
34
|
+
['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'].forEach((sig) => {
|
|
35
|
+
process.on(sig, () => child.kill(sig));
|
|
36
|
+
});
|
|
37
|
+
// Windows spawn .exe 时 kill('SIGTERM') 可能不生效,兜底强杀。
|
|
38
|
+
process.on('exit', () => {
|
|
39
|
+
if (!child.killed) child.kill();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.on('error', (err) => {
|
|
43
|
+
console.error(`✗ 无法执行 ${brand} 二进制:${err.message}`);
|
|
44
|
+
process.exit(126);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.on('close', (code, signal) => {
|
|
48
|
+
if (signal) {
|
|
49
|
+
process.exit(128);
|
|
50
|
+
}
|
|
51
|
+
process.exit(code ?? 1);
|
|
52
|
+
});
|
package/bin/multica.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// bin wrapper:把参数原样转给 vendor/ 下的平台二进制。
|
|
5
|
+
// multica 是兼容别名入口(对外品牌命令是 aido,见 bin/aido.js)。
|
|
6
|
+
// 二进制的子命令(daemon/login/config/...)与参数原样透传。
|
|
7
|
+
|
|
8
|
+
const { spawn } = require('child_process');
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
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';
|
|
16
|
+
|
|
17
|
+
const plat = detectPlatform();
|
|
18
|
+
const binary =
|
|
19
|
+
plat && vendorBinaryPath(path.join(__dirname, '..', 'vendor'), plat);
|
|
20
|
+
|
|
21
|
+
if (!binary || !fs.existsSync(binary)) {
|
|
22
|
+
console.error(`✗ ${brand} 二进制尚未下载。`);
|
|
23
|
+
console.error(' 修复方式:npm rebuild @asiainfo-sdd/multica-cli');
|
|
24
|
+
console.error(' (或设置 MULTICA_SKIP_BINARY_INSTALL=1 显式跳过后自备二进制)');
|
|
25
|
+
process.exit(127);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const child = spawn(binary, process.argv.slice(2), {
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
env: process.env,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Windows 上 Ctrl-C 走 SIGINT/Break;转发常见终止信号。
|
|
34
|
+
['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGBREAK'].forEach((sig) => {
|
|
35
|
+
process.on(sig, () => child.kill(sig));
|
|
36
|
+
});
|
|
37
|
+
// Windows spawn .exe 时 kill('SIGTERM') 可能不生效,兜底强杀。
|
|
38
|
+
process.on('exit', () => {
|
|
39
|
+
if (!child.killed) child.kill();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
child.on('error', (err) => {
|
|
43
|
+
console.error(`✗ 无法执行 ${brand} 二进制:${err.message}`);
|
|
44
|
+
process.exit(126);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.on('close', (code, signal) => {
|
|
48
|
+
if (signal) {
|
|
49
|
+
process.exit(128);
|
|
50
|
+
}
|
|
51
|
+
process.exit(code ?? 1);
|
|
52
|
+
});
|
package/lib/http.js
ADDED
|
@@ -0,0 +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 };
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// postinstall 主流程:按平台下载 multica CLI 二进制(-tags agentfoundry
|
|
4
|
+
// 构建,含 daemon 主机线能力)并落位到包内 vendor/。
|
|
5
|
+
// 只管安装:不读写 ~/.multica 配置、不登录、不启动/停止 daemon。
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const { spawnSync } = require('child_process');
|
|
11
|
+
|
|
12
|
+
const { detectPlatform, manifestKey, vendorBinaryPath } = require('./platform');
|
|
13
|
+
const { applyServerConfig } = require('./server-config');
|
|
14
|
+
const http = require('./http');
|
|
15
|
+
|
|
16
|
+
const pkg = require('../package.json');
|
|
17
|
+
|
|
18
|
+
// 下载源基址,可被环境变量覆盖。指向 Nexus raw 仓库或 Jenkins 产物目录。
|
|
19
|
+
const DEFAULT_DOWNLOAD_BASE = 'https://aido.asiainfo.com/aird-api/business/api/v1/external/daemon-download';
|
|
20
|
+
const LOCK_STALE_MS = 5 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
function log(msg) {
|
|
23
|
+
process.stdout.write(`${msg}\n`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function downloadBase() {
|
|
27
|
+
return (process.env.MULTICA_DOWNLOAD_BASE || DEFAULT_DOWNLOAD_BASE).replace(/\/+$/, '');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function vendorDir() {
|
|
31
|
+
return path.join(__dirname, '..', 'vendor');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function installedMarkerPath() {
|
|
35
|
+
return path.join(vendorDir(), '.installed.json');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readInstalled() {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(fs.readFileSync(installedMarkerPath(), 'utf8'));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------- manifest ----------
|
|
47
|
+
|
|
48
|
+
function fallbackManifest() {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(
|
|
51
|
+
fs.readFileSync(path.join(__dirname, 'manifest.fallback.json'), 'utf8'),
|
|
52
|
+
);
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function loadManifest() {
|
|
59
|
+
const base = downloadBase();
|
|
60
|
+
try {
|
|
61
|
+
const text = await http.fetchText(`${base}/manifest.json`, { timeoutMs: 10000 });
|
|
62
|
+
return { manifest: JSON.parse(text), source: base };
|
|
63
|
+
} catch (err) {
|
|
64
|
+
log(`⚠ 无法从 ${base} 获取 manifest(${err.message}),改用包内置兜底清单`);
|
|
65
|
+
const fb = fallbackManifest();
|
|
66
|
+
if (fb) return { manifest: fb, source: 'fallback' };
|
|
67
|
+
return { manifest: null, source: 'none' };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function pickEntry(manifest, version) {
|
|
72
|
+
return manifest?.versions?.[version] || null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------- 锁 ----------
|
|
76
|
+
|
|
77
|
+
function acquireLock(dir) {
|
|
78
|
+
const lockPath = path.join(dir, '.lock');
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
for (let attempt = 0; ; attempt++) {
|
|
81
|
+
try {
|
|
82
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
83
|
+
fs.writeSync(fd, String(process.pid));
|
|
84
|
+
fs.closeSync(fd);
|
|
85
|
+
return { release: () => fs.rmSync(lockPath, { force: true }) };
|
|
86
|
+
} catch (err) {
|
|
87
|
+
if (err.code !== 'EEXIST') throw err;
|
|
88
|
+
try {
|
|
89
|
+
const st = fs.statSync(lockPath);
|
|
90
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) {
|
|
91
|
+
fs.rmSync(lockPath, { force: true });
|
|
92
|
+
continue; // 陈旧锁已破,重试获取
|
|
93
|
+
}
|
|
94
|
+
} catch {
|
|
95
|
+
continue; // 锁文件刚被对方释放
|
|
96
|
+
}
|
|
97
|
+
if (attempt > 60) {
|
|
98
|
+
throw new Error('另一进程正在下载 multica 二进制,等待超时;请稍后重试 npm rebuild');
|
|
99
|
+
}
|
|
100
|
+
const until = Date.now() + 2000;
|
|
101
|
+
while (Date.now() < until) {} // 同步安装脚本内简短等待
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------- 下载与校验 ----------
|
|
107
|
+
|
|
108
|
+
function progressPrinter(isTTY) {
|
|
109
|
+
if (!isTTY) {
|
|
110
|
+
let announced = false;
|
|
111
|
+
return (done, total) => {
|
|
112
|
+
if (!announced) {
|
|
113
|
+
announced = true;
|
|
114
|
+
log(`… 开始下载${total ? `(共 ${(total / 1048576).toFixed(1)} MB)` : ''},完成后报告`);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return (done, total) => {
|
|
119
|
+
const mb = (n) => (n / 1048576).toFixed(1);
|
|
120
|
+
const pct = total ? ` (${Math.floor((done / total) * 100)}%)` : '';
|
|
121
|
+
process.stdout.write(`\r 下载中 ${mb(done)}${total ? `/${mb(total)} MB` : ' MB'}${pct} `);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sha256Of(filePath) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const hash = crypto.createHash('sha256');
|
|
128
|
+
const stream = fs.createReadStream(filePath);
|
|
129
|
+
stream.on('error', reject);
|
|
130
|
+
stream.on('data', (c) => hash.update(c));
|
|
131
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function downloadOnce(url, tmpPath, expectedSize) {
|
|
136
|
+
await new Promise((resolve, reject) => {
|
|
137
|
+
fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
|
|
138
|
+
const out = fs.createWriteStream(tmpPath);
|
|
139
|
+
const onProgress = progressPrinter(process.stdout.isTTY);
|
|
140
|
+
http
|
|
141
|
+
.downloadToStream(url, out, { onProgress })
|
|
142
|
+
.then(resolve, reject);
|
|
143
|
+
});
|
|
144
|
+
if (process.stdout.isTTY) process.stdout.write('\n');
|
|
145
|
+
if (expectedSize && fs.statSync(tmpPath).size !== expectedSize) {
|
|
146
|
+
throw new Error(`大小不符:期望 ${expectedSize},实际 ${fs.statSync(tmpPath).size}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function hintRunRebuild() {
|
|
151
|
+
log(' 修复方式:稍后执行 npm rebuild @asiainfo-sdd/multica-cli');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 把部署级配置(server_url/app_url)合并进 ~/.multica/config.json。
|
|
155
|
+
// manifest 失败时传 null,退回 env > 包内置默认值。
|
|
156
|
+
function applyServerConfigSafe(manifest) {
|
|
157
|
+
let result;
|
|
158
|
+
try {
|
|
159
|
+
result = applyServerConfig(manifest);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
log(`⚠ 写入服务器配置失败(${err.message}),请手工执行 aido config set server_url <地址>`);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (!result) return;
|
|
165
|
+
for (const { key, value } of result.written) {
|
|
166
|
+
log(`✓ 已写入 ${key} = ${value}(${require('./server-config').multicaConfigPath()})`);
|
|
167
|
+
}
|
|
168
|
+
for (const { key, existing } of result.kept) {
|
|
169
|
+
log(`ℹ ${key} 已有配置 ${existing},保持不变(如需更改:aido config set ${key} <新地址>)`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function install() {
|
|
174
|
+
if (process.env.MULTICA_SKIP_BINARY_INSTALL === '1') {
|
|
175
|
+
log('ℹ MULTICA_SKIP_BINARY_INSTALL=1,跳过二进制下载。');
|
|
176
|
+
log(' 需要时执行 npm rebuild @asiainfo-sdd/multica-cli 完成下载。');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const plat = detectPlatform();
|
|
181
|
+
if (!plat) {
|
|
182
|
+
log(`⚠ 当前平台 ${process.platform}/${process.arch} 不在支持矩阵(darwin/linux/windows × amd64/arm64),跳过下载。`);
|
|
183
|
+
log(' 如需在其他机器安装,请确认平台受支持后重试。');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const version = pkg.version;
|
|
188
|
+
const key = manifestKey(plat);
|
|
189
|
+
const { manifest } = await loadManifest();
|
|
190
|
+
const versionEntry = pickEntry(manifest, version);
|
|
191
|
+
const entry = versionEntry?.[key];
|
|
192
|
+
|
|
193
|
+
if (!entry) {
|
|
194
|
+
log(`⚠ 下载清单中未找到 v${version} 的 ${key} 产物,暂不下载。`);
|
|
195
|
+
log(` 下载源:${downloadBase()}(可用 MULTICA_DOWNLOAD_BASE 覆盖)`);
|
|
196
|
+
hintRunRebuild();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const dir = vendorDir();
|
|
201
|
+
const target = vendorBinaryPath(dir, plat);
|
|
202
|
+
|
|
203
|
+
// 幂等:已装同版本且校验通过则跳过。
|
|
204
|
+
const installed = readInstalled();
|
|
205
|
+
if (
|
|
206
|
+
installed &&
|
|
207
|
+
installed.version === version &&
|
|
208
|
+
installed.sha256 === entry.sha256 &&
|
|
209
|
+
fs.existsSync(target)
|
|
210
|
+
) {
|
|
211
|
+
log(`✓ aido v${version} 已就绪(${path.basename(target)},无需重复下载)`);
|
|
212
|
+
applyServerConfigSafe(manifest);
|
|
213
|
+
printUsage(version);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const url = `${downloadBase()}/${entry.path.replace(/^\/+/, '')}`;
|
|
218
|
+
log(`→ 安装 aido(daemon 客户端)v${version}(${key})`);
|
|
219
|
+
log(` 下载源:${url}`);
|
|
220
|
+
|
|
221
|
+
const lock = acquireLock(dir);
|
|
222
|
+
try {
|
|
223
|
+
// 拿到锁后复查:等锁期间对方可能已完成安装。
|
|
224
|
+
const recheck = readInstalled();
|
|
225
|
+
if (
|
|
226
|
+
recheck &&
|
|
227
|
+
recheck.version === version &&
|
|
228
|
+
recheck.sha256 === entry.sha256 &&
|
|
229
|
+
fs.existsSync(target)
|
|
230
|
+
) {
|
|
231
|
+
log(`✓ aido v${version} 已就绪(并发安装合并,跳过下载)`);
|
|
232
|
+
printUsage(version);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const tmpPath = path.join(dir, `.tmp-${process.pid}`);
|
|
237
|
+
let lastErr = null;
|
|
238
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
239
|
+
try {
|
|
240
|
+
await downloadOnce(url, tmpPath, entry.size);
|
|
241
|
+
const actual = await sha256Of(tmpPath);
|
|
242
|
+
if (actual.toLowerCase() !== entry.sha256.toLowerCase()) {
|
|
243
|
+
throw new Error(`sha256 校验失败:期望 ${entry.sha256},实际 ${actual}`);
|
|
244
|
+
}
|
|
245
|
+
lastErr = null;
|
|
246
|
+
break;
|
|
247
|
+
} catch (err) {
|
|
248
|
+
lastErr = err;
|
|
249
|
+
fs.rmSync(tmpPath, { force: true });
|
|
250
|
+
if (attempt === 1) log(` 下载/校验失败(${err.message}),重试一次…`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (lastErr) {
|
|
254
|
+
log(`✗ 二进制下载失败:${lastErr.message}`);
|
|
255
|
+
log(` 请检查网络与下载源 ${downloadBase()} 是否可达(代理变量 http_proxy/https_proxy 会被识别)`);
|
|
256
|
+
hintRunRebuild();
|
|
257
|
+
process.exitCode = 1;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
fs.rmSync(target, { force: true });
|
|
263
|
+
fs.renameSync(tmpPath, target);
|
|
264
|
+
} catch (err) {
|
|
265
|
+
if (err.code === 'EPERM' || err.code === 'EBUSY' || err.code === 'EACCES') {
|
|
266
|
+
log('✗ 无法替换二进制文件:文件被占用(通常是 daemon 正在运行)。');
|
|
267
|
+
log(' 请先执行 multica daemon stop,再运行 npm rebuild @asiainfo-sdd/multica-cli。');
|
|
268
|
+
} else {
|
|
269
|
+
log(`✗ 落位失败:${err.message}`);
|
|
270
|
+
}
|
|
271
|
+
hintRunRebuild();
|
|
272
|
+
process.exitCode = 1;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (process.platform !== 'win32') fs.chmodSync(target, 0o755);
|
|
277
|
+
if (process.platform === 'darwin') {
|
|
278
|
+
// ad-hoc 签名,避免 Gatekeeper 拦截 spawn;失败不致命。
|
|
279
|
+
const r = spawnSync('codesign', ['-s', '-', '--force', target], { stdio: 'ignore' });
|
|
280
|
+
if (r.status !== 0) log(' (codesign ad-hoc 签名失败,可忽略)');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
fs.writeFileSync(
|
|
284
|
+
installedMarkerPath(),
|
|
285
|
+
JSON.stringify(
|
|
286
|
+
{ version, sha256: entry.sha256, installedAt: new Date().toISOString() },
|
|
287
|
+
null,
|
|
288
|
+
2,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
log(`✓ aido v${version} 安装完成:${target}`);
|
|
292
|
+
applyServerConfigSafe(manifest);
|
|
293
|
+
printUsage(version);
|
|
294
|
+
} finally {
|
|
295
|
+
lock.release();
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function printUsage(version) {
|
|
300
|
+
log('');
|
|
301
|
+
log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
302
|
+
log(` aido(daemon 客户端)v${version} 已就绪`);
|
|
303
|
+
const configPath = require('./server-config').multicaConfigPath();
|
|
304
|
+
// 以用户配置文件里最终的 server_url 为准(含此前手工配置过的场景)。
|
|
305
|
+
let effectiveServerUrl = '';
|
|
306
|
+
try {
|
|
307
|
+
effectiveServerUrl =
|
|
308
|
+
JSON.parse(require('fs').readFileSync(configPath, 'utf8')).server_url || '';
|
|
309
|
+
} catch {
|
|
310
|
+
// 文件缺失/损坏:走三步指引
|
|
311
|
+
}
|
|
312
|
+
if (effectiveServerUrl) {
|
|
313
|
+
log(` ✓ 服务器地址已就绪:${configPath}(server_url = ${effectiveServerUrl})`);
|
|
314
|
+
log(' 接下来请自行完成两步:');
|
|
315
|
+
log(' 1. aido login --token mul_<个人令牌> ← 令牌在 Web 端“设置→令牌”生成');
|
|
316
|
+
log(' 2. aido daemon start ← 需本机已装至少一个 agent CLI(claude/codex/opencode…)');
|
|
317
|
+
} else {
|
|
318
|
+
log(' 接下来请自行完成三步:');
|
|
319
|
+
log(' 1. aido config set server_url https://<服务器地址>');
|
|
320
|
+
log(' 2. aido login --token mul_<个人令牌> ← 令牌在 Web 端“设置→令牌”生成');
|
|
321
|
+
log(' 3. aido daemon start ← 需本机已装至少一个 agent CLI(claude/codex/opencode…)');
|
|
322
|
+
}
|
|
323
|
+
log(' 日常管理:aido daemon status / logs / stop');
|
|
324
|
+
log(' 提示“aido 不是命令” → 重新打开终端,或用 npx aido ...');
|
|
325
|
+
log(' 本包只负责安装与部署级配置:不会写 token、不会登录、不会启动 daemon。');
|
|
326
|
+
log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
install().catch((err) => {
|
|
330
|
+
log(`✗ 安装脚本异常:${err.stack || err.message}`);
|
|
331
|
+
hintRunRebuild();
|
|
332
|
+
process.exitCode = 1;
|
|
333
|
+
});
|
package/lib/platform.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// platform/arch → 下载资产维度。产物命名与 multica 仓
|
|
4
|
+
// server/extensions/agentfoundry/build/build.sh 的 cli-all 模式对齐:
|
|
5
|
+
// multica-af-<os>-<arch>[.exe]
|
|
6
|
+
|
|
7
|
+
const OS_MAP = {
|
|
8
|
+
darwin: 'darwin',
|
|
9
|
+
linux: 'linux',
|
|
10
|
+
win32: 'windows',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ARCH_MAP = {
|
|
14
|
+
x64: 'amd64',
|
|
15
|
+
arm64: 'arm64',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// 返回 null 表示当前平台不在 6 平台支持矩阵内。
|
|
19
|
+
function detectPlatform(platform = process.platform, arch = process.arch) {
|
|
20
|
+
const os = OS_MAP[platform];
|
|
21
|
+
const mappedArch = ARCH_MAP[arch];
|
|
22
|
+
if (!os || !mappedArch) return null;
|
|
23
|
+
return { os, arch: mappedArch, ext: platform === 'win32' ? '.exe' : '' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assetName({ os, arch, ext }) {
|
|
27
|
+
return `multica-af-${os}-${arch}${ext}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// manifest.json 里的平台键。
|
|
31
|
+
function manifestKey({ os, arch }) {
|
|
32
|
+
return `${os}-${arch}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function vendorBinaryPath(vendorDir, plat) {
|
|
36
|
+
const path = require('path');
|
|
37
|
+
return path.join(vendorDir, assetName(plat));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = {
|
|
41
|
+
detectPlatform,
|
|
42
|
+
assetName,
|
|
43
|
+
manifestKey,
|
|
44
|
+
vendorBinaryPath,
|
|
45
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// 安装时把部署级配置(server_url/app_url)合并写入 ~/.multica/config.json,
|
|
4
|
+
// 免去用户手工 multica config set。规则:
|
|
5
|
+
// - 取值优先级: MULTICA_SERVER_URL/MULTICA_APP_URL env > manifest 顶层
|
|
6
|
+
// server_url/app_url(运维可不重发 npm 包直接改) > lib/dist-config.json
|
|
7
|
+
// - 只填空: 用户已有值(如指向测试服)绝不覆盖,只提示
|
|
8
|
+
// - 目标文件是 multica CLI 的标准配置(daemon 只认这里的 token),
|
|
9
|
+
// 多 profile 场景(--profile)不在此处理,留给用户手动 config set
|
|
10
|
+
// token 属于个人凭据,始终由用户自行 multica login --token 写入。
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const os = require('os');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
const CONFIG_KEYS = ['server_url', 'app_url'];
|
|
17
|
+
|
|
18
|
+
function multicaConfigPath() {
|
|
19
|
+
return path.join(os.homedir(), '.multica', 'config.json');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readUserConfig() {
|
|
23
|
+
const file = multicaConfigPath();
|
|
24
|
+
let raw;
|
|
25
|
+
try {
|
|
26
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.code === 'ENOENT') return { config: {}, existed: false };
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
const config = JSON.parse(raw); // 解析失败向上抛,由调用方决定跳过
|
|
32
|
+
return { config, existed: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// env > manifest > dist-config;返回只含非空键的 {server_url?, app_url?}。
|
|
36
|
+
function resolveDesired(manifest) {
|
|
37
|
+
const distConfig = require('./dist-config.json');
|
|
38
|
+
const desired = {};
|
|
39
|
+
const fromEnv = {
|
|
40
|
+
server_url: process.env.MULTICA_SERVER_URL,
|
|
41
|
+
app_url: process.env.MULTICA_APP_URL,
|
|
42
|
+
};
|
|
43
|
+
for (const key of CONFIG_KEYS) {
|
|
44
|
+
const value =
|
|
45
|
+
(fromEnv[key] && fromEnv[key].trim()) ||
|
|
46
|
+
(manifest && typeof manifest[key] === 'string' && manifest[key].trim()) ||
|
|
47
|
+
(typeof distConfig[key] === 'string' && distConfig[key].trim()) ||
|
|
48
|
+
'';
|
|
49
|
+
if (value) desired[key] = value;
|
|
50
|
+
}
|
|
51
|
+
return desired;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 返回 {written: [{key, value}], kept: [{key, existing}]} 供上层打印;
|
|
55
|
+
// 无可写内容或文件损坏时返回 null(不产生任何变更)。
|
|
56
|
+
function applyServerConfig(manifest) {
|
|
57
|
+
const desired = resolveDesired(manifest);
|
|
58
|
+
if (!desired.server_url && !desired.app_url) return null;
|
|
59
|
+
|
|
60
|
+
let userConfig;
|
|
61
|
+
let existed;
|
|
62
|
+
try {
|
|
63
|
+
({ config: userConfig, existed } = readUserConfig());
|
|
64
|
+
} catch {
|
|
65
|
+
logWarn(`已存在 ${multicaConfigPath()} 但无法解析,已跳过配置写入——请手工 multica config set server_url`);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const written = [];
|
|
70
|
+
const kept = [];
|
|
71
|
+
for (const key of CONFIG_KEYS) {
|
|
72
|
+
if (!desired[key]) continue;
|
|
73
|
+
if (userConfig[key] && userConfig[key] !== desired[key]) {
|
|
74
|
+
kept.push({ key, existing: userConfig[key] });
|
|
75
|
+
} else if (!userConfig[key]) {
|
|
76
|
+
userConfig[key] = desired[key];
|
|
77
|
+
written.push({ key, value: desired[key] });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (written.length === 0) return { written, kept };
|
|
82
|
+
|
|
83
|
+
const file = multicaConfigPath();
|
|
84
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
85
|
+
// 保留用户已有其余字段,仅追加部署级键;不写 token。
|
|
86
|
+
fs.writeFileSync(file, JSON.stringify(userConfig, null, 2) + '\n');
|
|
87
|
+
return { written, kept };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function logWarn(msg) {
|
|
91
|
+
process.stdout.write(`⚠ ${msg}\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = { applyServerConfig, multicaConfigPath, resolveDesired };
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asiainfo-sdd/multica-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "数字工厂 daemon 客户端安装帮助包:npm install 时按平台下载二进制并校验并写入部署级配置,不参与 daemon 的使用与管理",
|
|
5
|
+
"bin": {
|
|
6
|
+
"aido": "bin/aido.js",
|
|
7
|
+
"multica": "bin/multica.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"postinstall": "node lib/install.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"lib",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=18"
|
|
19
|
+
},
|
|
20
|
+
"license": "UNLICENSED",
|
|
21
|
+
"multica": {
|
|
22
|
+
"configKeysWrittenAtInstall": ["server_url", "app_url"],
|
|
23
|
+
"notes": "安装时只填空写入部署级配置(env MULTICA_SERVER_URL/MULTICA_APP_URL > manifest 顶层 > lib/dist-config.json);不写 token、不覆盖已有值"
|
|
24
|
+
}
|
|
25
|
+
}
|