openclaw-yunjia-cli 0.0.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.
- package/LICENSE +21 -0
- package/README.md +338 -0
- package/cli.mjs +1045 -0
- package/lib/compat.mjs +45 -0
- package/package.json +31 -0
package/cli.mjs
ADDED
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import readline from "node:readline";
|
|
8
|
+
import {
|
|
9
|
+
COMPAT_MATRIX,
|
|
10
|
+
findCompatEntry,
|
|
11
|
+
formatRange,
|
|
12
|
+
} from "./lib/compat.mjs";
|
|
13
|
+
|
|
14
|
+
const PLUGIN_SPEC = "openclaw-inspur-yunjia";
|
|
15
|
+
const CHANNEL_ID = "openclaw-inspur-yunjia";
|
|
16
|
+
const CHANNEL_KEY = "openclaw-inspur-yunjia";
|
|
17
|
+
|
|
18
|
+
// ── openclaw 路径配置 ──────────────────────────────────────────────────────────
|
|
19
|
+
// 尝试从常见位置找到 openclaw
|
|
20
|
+
function resolveOpenclawPath() {
|
|
21
|
+
// 检查 PATH 环境变量中是否有 openclaw
|
|
22
|
+
const pathDirs = (process.env.PATH || "").split(":");
|
|
23
|
+
for (const dir of pathDirs) {
|
|
24
|
+
const openclawPath = path.join(dir, "openclaw");
|
|
25
|
+
try {
|
|
26
|
+
fs.accessSync(openclawPath, fs.constants.X_OK);
|
|
27
|
+
return openclawPath;
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 常见位置
|
|
32
|
+
const commonPaths = [
|
|
33
|
+
"/Users/til/code/openclaw/openclaw/dist/index.js",
|
|
34
|
+
path.join(os.homedir(), "code/openclaw/openclaw/dist/index.js"),
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
for (const p of commonPaths) {
|
|
38
|
+
if (fs.existsSync(p)) {
|
|
39
|
+
return p;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return "openclaw";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const OPENCLAW_CMD = resolveOpenclawPath();
|
|
47
|
+
|
|
48
|
+
// ── 云加服务端 API 配置 ─────────────────────────────────────────────────────
|
|
49
|
+
// 二维码绑定流程的 API 地址,根据部署环境配置
|
|
50
|
+
// 优先使用环境变量 YUNJIA_BINDING_API_BASE,其次使用命令行参数 --api-base
|
|
51
|
+
// 开发环境可设置为 http://localhost:3210
|
|
52
|
+
const DEFAULT_BINDING_API_BASE = process.env.NODE_ENV === "development"
|
|
53
|
+
? "http://localhost:3210"
|
|
54
|
+
: "http://8.145.41.158:3210";
|
|
55
|
+
|
|
56
|
+
// ── Verbose mode ────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
const verbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
|
59
|
+
|
|
60
|
+
// 从 argv 中移除 flags,保留命令和位置参数
|
|
61
|
+
function stripFlags(argv) {
|
|
62
|
+
const result = [];
|
|
63
|
+
for (let i = 0; i < argv.length; i++) {
|
|
64
|
+
const a = argv[i];
|
|
65
|
+
// 跳过已知 flags
|
|
66
|
+
if (a === "--verbose" || a === "-v") continue;
|
|
67
|
+
// 跳过 --api-base/--server 及其值
|
|
68
|
+
if (a === "--api-base" || a === "--server") {
|
|
69
|
+
i++; // 跳过下一个参数(值)
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// 跳过 --dev-package 及其值
|
|
73
|
+
if (a === "--dev-package") {
|
|
74
|
+
i++; // 跳过下一个参数(值)
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
result.push(a);
|
|
78
|
+
}
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
const cleanArgv = stripFlags(process.argv.slice(2));
|
|
82
|
+
|
|
83
|
+
// 从环境变量或命令行参数读取绑定服务地址
|
|
84
|
+
// 环境变量: YUNJIA_BINDING_API_BASE
|
|
85
|
+
// 命令行参数: --api-base <url> 或 --server <url>
|
|
86
|
+
function resolveBindingApiBase() {
|
|
87
|
+
// 优先命令行参数(从原始 argv 读取,不是 cleanArgv)
|
|
88
|
+
const args = process.argv.slice(2);
|
|
89
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
90
|
+
if (args[i] === "--api-base" || args[i] === "--server") {
|
|
91
|
+
return args[i + 1];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// 其次环境变量
|
|
95
|
+
return process.env.YUNJIA_BINDING_API_BASE || DEFAULT_BINDING_API_BASE;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 从命令行参数读取本地开发包路径
|
|
99
|
+
// 命令行参数: --dev-package <path>
|
|
100
|
+
function resolveDevPackagePath() {
|
|
101
|
+
const args = process.argv.slice(2);
|
|
102
|
+
for (let i = 0; i < args.length - 1; i++) {
|
|
103
|
+
if (args[i] === "--dev-package") {
|
|
104
|
+
const pkgPath = args[i + 1];
|
|
105
|
+
// 转换为绝对路径
|
|
106
|
+
const absolutePath = path.resolve(pkgPath);
|
|
107
|
+
// 验证路径存在
|
|
108
|
+
if (fs.existsSync(absolutePath)) {
|
|
109
|
+
const pkgJsonPath = path.join(absolutePath, "package.json");
|
|
110
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
111
|
+
return absolutePath;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
warn(`警告: 开发包路径不存在或不是有效的包目录: ${absolutePath}`);
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── 时间配置(与服务端保持一致)────────────────────────────────────────────────────
|
|
122
|
+
const QR_EXPIRE_SECONDS = 60; // 二维码有效期(秒)
|
|
123
|
+
const SCANNED_CONFIRM_TIMEOUT_SEC = 60; // 扫码后等待确认的超时时间(秒)
|
|
124
|
+
const MAX_POLL_RETRIES = 3; // 短轮询模式最大重试次数
|
|
125
|
+
const POLL_INTERVAL_MS = 5000; // 短轮询间隔(毫秒)
|
|
126
|
+
|
|
127
|
+
function log(msg) {
|
|
128
|
+
if (verbose) console.log(`\x1b[36m${msg}\x1b[0m`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function logAlways(msg) {
|
|
132
|
+
console.log(`\x1b[36m${msg}\x1b[0m`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function error(msg) {
|
|
136
|
+
console.error(`\x1b[31m${msg}\x1b[0m`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function success(msg) {
|
|
140
|
+
console.log(`\x1b[32m${msg}\x1b[0m`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function warn(msg) {
|
|
144
|
+
console.log(`\x1b[33m${msg}\x1b[0m`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function run(cmd, { silent = true } = {}) {
|
|
148
|
+
const stdio = silent ? ["pipe", "pipe", "pipe"] : "inherit";
|
|
149
|
+
const result = spawnSync(cmd, { shell: true, stdio });
|
|
150
|
+
if (result.status !== 0) {
|
|
151
|
+
const err = new Error(`Command failed with exit code ${result.status}: ${cmd}`);
|
|
152
|
+
err.stderr = silent ? (result.stderr || "").toString() : "";
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
return silent ? (result.stdout || "").toString().trim() : "";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function which(bin) {
|
|
159
|
+
// 使用 command -v 替代 which,因为 zsh 中 which 对别名返回非零退出码
|
|
160
|
+
const cmd = process.platform === "win32" ? `where ${bin}` : `command -v ${bin}`;
|
|
161
|
+
try {
|
|
162
|
+
const result = execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
163
|
+
// command -v 对 alias 返回别名字符串,不是路径
|
|
164
|
+
// 如果返回包含空格/=,说明是 alias,尝试通过 PATH 查找
|
|
165
|
+
if (result.includes(" ") || result.startsWith(bin + "=")) {
|
|
166
|
+
const pathCmd = process.platform === "win32" ? `where ${bin}` : `echo $PATH | tr ':' '\\n' | while read dir; do [ -x "$dir/${bin}" ] && echo "$dir/${bin}" && break; done`;
|
|
167
|
+
try {
|
|
168
|
+
const pathResult = execSync(pathCmd, { shell: true, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
|
169
|
+
return pathResult || null;
|
|
170
|
+
} catch {
|
|
171
|
+
// 直接调用检查
|
|
172
|
+
try {
|
|
173
|
+
execSync(`"${result}" --version`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
174
|
+
return result;
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return result || null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function createRL() {
|
|
187
|
+
return readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function prompt(rl, text) {
|
|
191
|
+
return new Promise((resolve) => {
|
|
192
|
+
rl.question(text, (answer) => resolve(answer.trim()));
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── version detection ───────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
function getOpenclawVersion() {
|
|
199
|
+
try {
|
|
200
|
+
const raw = run(`${OPENCLAW_CMD} --version`);
|
|
201
|
+
const match = raw.match(/(\d+\.\d+\.\d+)/);
|
|
202
|
+
return match ? match[1] : null;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function selectPluginTag(openclawVersion) {
|
|
209
|
+
const entry = findCompatEntry(openclawVersion);
|
|
210
|
+
if (entry) return entry;
|
|
211
|
+
error(`当前 OpenClaw 版本 ${openclawVersion} 不在任何已知兼容范围内`);
|
|
212
|
+
console.log("\n 已知兼容矩阵:");
|
|
213
|
+
for (const e of COMPAT_MATRIX) {
|
|
214
|
+
console.log(` ${e.label} → OpenClaw ${formatRange(e.openclawRange)}`);
|
|
215
|
+
}
|
|
216
|
+
console.log();
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── symlink ──────────────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function resolveHostOpenclawRoot() {
|
|
223
|
+
const bin = which("openclaw");
|
|
224
|
+
if (!bin) return null;
|
|
225
|
+
try {
|
|
226
|
+
const real = fs.realpathSync(bin);
|
|
227
|
+
let dir = path.dirname(real);
|
|
228
|
+
for (let i = 0; i < 6; i++) {
|
|
229
|
+
try {
|
|
230
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8"));
|
|
231
|
+
if (pkg.name === "openclaw") return dir;
|
|
232
|
+
} catch {}
|
|
233
|
+
const parent = path.dirname(dir);
|
|
234
|
+
if (parent === dir) break;
|
|
235
|
+
dir = parent;
|
|
236
|
+
}
|
|
237
|
+
} catch {}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function resolvePluginExtDir() {
|
|
242
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), ".openclaw");
|
|
243
|
+
return path.join(stateDir, "extensions", CHANNEL_ID);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function ensureOpenclawSymlink() {
|
|
247
|
+
const hostRoot = resolveHostOpenclawRoot();
|
|
248
|
+
if (!hostRoot) {
|
|
249
|
+
log("无法定位宿主 openclaw 包根目录,跳过 symlink 创建");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const pluginDir = resolvePluginExtDir();
|
|
253
|
+
if (!fs.existsSync(pluginDir)) return;
|
|
254
|
+
const nmDir = path.join(pluginDir, "node_modules");
|
|
255
|
+
const linkPath = path.join(nmDir, "openclaw");
|
|
256
|
+
try {
|
|
257
|
+
const existing = fs.readlinkSync(linkPath);
|
|
258
|
+
if (fs.realpathSync(existing) === fs.realpathSync(hostRoot)) {
|
|
259
|
+
log("openclaw symlink 已存在且正确");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
fs.unlinkSync(linkPath);
|
|
263
|
+
} catch {}
|
|
264
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
265
|
+
fs.symlinkSync(hostRoot, linkPath);
|
|
266
|
+
log(`已创建 symlink: node_modules/openclaw → ${hostRoot}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── config management ────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function getConfigPath() {
|
|
272
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), ".openclaw");
|
|
273
|
+
return path.join(stateDir, "openclaw.json");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function readConfig() {
|
|
277
|
+
const cfgPath = getConfigPath();
|
|
278
|
+
if (!fs.existsSync(cfgPath)) return {};
|
|
279
|
+
return JSON.parse(fs.readFileSync(cfgPath, "utf-8"));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function writeConfig(config) {
|
|
283
|
+
const cfgPath = getConfigPath();
|
|
284
|
+
config.meta = config.meta || {};
|
|
285
|
+
config.meta.lastTouchedAt = new Date().toISOString();
|
|
286
|
+
fs.writeFileSync(cfgPath, JSON.stringify(config, null, 2));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* 将绑定结果写入 openclaw 配置。
|
|
291
|
+
* 根据时序图步骤 9-10:服务端返回 botId + botSecret + 绑定用户信息。
|
|
292
|
+
*/
|
|
293
|
+
function addAccountFromBinding({ botId, botSecret, userId, userName, enterprise, baseChatUrl, accountId = "default" }) {
|
|
294
|
+
const config = readConfig();
|
|
295
|
+
if (!config.channels) config.channels = {};
|
|
296
|
+
const yunjia = config.channels[CHANNEL_KEY] || {};
|
|
297
|
+
config.channels[CHANNEL_KEY] = yunjia;
|
|
298
|
+
|
|
299
|
+
if (accountId === "default") {
|
|
300
|
+
yunjia.botId = botId;
|
|
301
|
+
yunjia.botSecret = botSecret;
|
|
302
|
+
if (enterprise) yunjia.enterprise = enterprise;
|
|
303
|
+
yunjia.enabled = true;
|
|
304
|
+
if (userId) yunjia.userId = String(userId);
|
|
305
|
+
if (userName) yunjia.userName = userName;
|
|
306
|
+
yunjia.passport = {
|
|
307
|
+
baseUrl: process.env.YUNJIA_BASE_URL || "https://id.inspuronline.com",
|
|
308
|
+
clientSecret: process.env.YUNJIA_CLIENT_SECRET || "66dea8c2-9a8b-41f6-8e70-fada47b3e104",
|
|
309
|
+
clientId: process.env.YUNJIA_CLIENT_ID || "com.inspur.ecm.client.openclaw",
|
|
310
|
+
...(baseChatUrl && { baseChatUrl }),
|
|
311
|
+
};
|
|
312
|
+
} else {
|
|
313
|
+
if (!yunjia.accounts) yunjia.accounts = {};
|
|
314
|
+
yunjia.accounts[accountId] = {
|
|
315
|
+
botId,
|
|
316
|
+
botSecret,
|
|
317
|
+
...(enterprise && { enterprise }),
|
|
318
|
+
enabled: true,
|
|
319
|
+
...(userId && { userId: String(userId) }),
|
|
320
|
+
...(userName && { userName }),
|
|
321
|
+
passport: {
|
|
322
|
+
baseUrl: process.env.YUNJIA_BASE_URL || "https://id.inspuronline.com",
|
|
323
|
+
clientSecret: process.env.YUNJIA_CLIENT_SECRET || "66dea8c2-9a8b-41f6-8e70-fada47b3e104",
|
|
324
|
+
clientId: process.env.YUNJIA_CLIENT_ID || "com.inspur.ecm.client.openclaw",
|
|
325
|
+
...(baseChatUrl && { baseChatUrl }),
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
if (!yunjia.defaultAccount) yunjia.defaultAccount = accountId;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
writeConfig(config);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// ── QR 码绑定流程(时序图核心) ─────────────────────────────────────────────
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* 步骤 1: CLI → 云加服务端 — 请求二维码
|
|
338
|
+
* 服务端生成绑定ID(过期时间 60 秒),返回二维码链接
|
|
339
|
+
*/
|
|
340
|
+
async function requestQRCode(apiBase) {
|
|
341
|
+
const url = `${apiBase}/api/v1/binding/qrcode`;
|
|
342
|
+
try {
|
|
343
|
+
const resp = await fetch(url, {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: { "Content-Type": "application/json" },
|
|
346
|
+
body: JSON.stringify({ expire_seconds: QR_EXPIRE_SECONDS }),
|
|
347
|
+
});
|
|
348
|
+
if (!resp.ok) {
|
|
349
|
+
const text = await resp.text();
|
|
350
|
+
throw new Error(`请求二维码失败 (${resp.status}): ${text}`);
|
|
351
|
+
}
|
|
352
|
+
return await resp.json();
|
|
353
|
+
// 返回: { binding_id, qrcode_url, expire_seconds, expire_at }
|
|
354
|
+
} catch (e) {
|
|
355
|
+
if (e instanceof TypeError && e.message.includes("fetch")) {
|
|
356
|
+
throw new Error(`无法连接绑定服务 (${apiBase}),请确认服务已启动`);
|
|
357
|
+
}
|
|
358
|
+
throw new Error(`请求二维码失败: ${e.message}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 步骤 4: CLI → 云加服务端 — HTTP 阻塞等待用户绑定
|
|
364
|
+
* 时序图注明:用户绑定成功前,重试 3 次,3 次后未绑定成功则放弃本次绑定,进程结束
|
|
365
|
+
*
|
|
366
|
+
* 使用 SSE (Server-Sent Events) 接收服务端推送的事件
|
|
367
|
+
*
|
|
368
|
+
* 返回值:
|
|
369
|
+
* - { status: "bound", ... } - 绑定成功
|
|
370
|
+
* - null - 过期或超时(通过 errorReason 字段区分具体原因)
|
|
371
|
+
* - { status: "scanned", timeout_seconds: N } - 已扫码,等待确认(含超时时间,已废弃,现在内部处理)
|
|
372
|
+
*/
|
|
373
|
+
async function pollBindingResult(apiBase, bindingId, maxRetries = MAX_POLL_RETRIES) {
|
|
374
|
+
// 使用 SSE (Server-Sent Events) 接收推送
|
|
375
|
+
const sseUrl = `${apiBase}/api/v1/binding/${bindingId}/result?stream=true`;
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
log(`等待用户扫码绑定(SSE,二维码有效期 ${QR_EXPIRE_SECONDS}s)...`);
|
|
379
|
+
log(`SSE URL: ${sseUrl}`);
|
|
380
|
+
const resp = await fetch(sseUrl);
|
|
381
|
+
|
|
382
|
+
if (!resp.ok) {
|
|
383
|
+
const text = await resp.text();
|
|
384
|
+
throw new Error(`SSE 连接失败 (${resp.status}): ${text}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 检查响应头是否是 SSE
|
|
388
|
+
const contentType = resp.headers.get("content-type");
|
|
389
|
+
log(`SSE Content-Type: ${contentType}`);
|
|
390
|
+
|
|
391
|
+
// 如果不是 SSE 响应(比如状态已经是 bound),直接解析 JSON
|
|
392
|
+
if (!contentType || !contentType.includes("text/event-stream")) {
|
|
393
|
+
log(`收到非 SSE 响应,直接解析 JSON`);
|
|
394
|
+
const data = await resp.json();
|
|
395
|
+
log(`收到数据: ${JSON.stringify(data)}`);
|
|
396
|
+
if (data.status === "bound") return data;
|
|
397
|
+
if (data.status === "scanned") return { status: "scanned", timeout_seconds: data.timeout_seconds || SCANNED_CONFIRM_TIMEOUT_SEC };
|
|
398
|
+
if (data.status === "expired") {
|
|
399
|
+
const reason = data.reason === "confirm_timeout" ? "确认超时" : data.reason === "timeout" ? "操作超时" : "二维码过期";
|
|
400
|
+
return { status: "expired", reason: data.reason || "qr_expired", message: reason };
|
|
401
|
+
}
|
|
402
|
+
return data;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// 读取 SSE 流
|
|
406
|
+
const reader = resp.body.getReader();
|
|
407
|
+
const decoder = new TextDecoder();
|
|
408
|
+
let buffer = "";
|
|
409
|
+
let scannedTimeout = null;
|
|
410
|
+
let eventCount = 0;
|
|
411
|
+
let scannedAt = null; // 记录扫码时间,用于客户端超时检查
|
|
412
|
+
const clientTimeoutMs = (SCANNED_CONFIRM_TIMEOUT_SEC + 10) * 1000; // 客户端超时(多给10秒缓冲)
|
|
413
|
+
|
|
414
|
+
while (true) {
|
|
415
|
+
// 客户端超时检查:如果已扫码且超过客户端超时时间,主动返回超时
|
|
416
|
+
if (scannedAt && Date.now() - scannedAt > clientTimeoutMs) {
|
|
417
|
+
log(`客户端超时检查: 扫码后已过 ${clientTimeoutMs}ms,主动返回确认超时`);
|
|
418
|
+
return { status: "expired", reason: "confirm_timeout", message: "确认超时" };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const { done, value } = await reader.read();
|
|
422
|
+
if (done) {
|
|
423
|
+
log(`SSE 连接关闭,共收到 ${eventCount} 个事件`);
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
428
|
+
buffer += chunk;
|
|
429
|
+
log(`收到 SSE 数据块 (${value.length} bytes)`);
|
|
430
|
+
|
|
431
|
+
// 处理 SSE 消息(格式: data: {...}\n\n)
|
|
432
|
+
const lines = buffer.split("\n");
|
|
433
|
+
buffer = lines.pop() || ""; // 保留未完成的行
|
|
434
|
+
|
|
435
|
+
for (const line of lines) {
|
|
436
|
+
if (line.startsWith("data: ")) {
|
|
437
|
+
eventCount++;
|
|
438
|
+
try {
|
|
439
|
+
const data = JSON.parse(line.slice(6));
|
|
440
|
+
log(`收到事件: ${data.status}${data.reason ? ` (${data.reason})` : ''}`);
|
|
441
|
+
if (data.status === "bound") {
|
|
442
|
+
log(`完整数据: ${JSON.stringify(data)}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (data.status === "bound") return data;
|
|
446
|
+
if (data.status === "scanned") {
|
|
447
|
+
scannedTimeout = data.timeout_seconds || SCANNED_CONFIRM_TIMEOUT_SEC;
|
|
448
|
+
scannedAt = Date.now(); // 记录扫码时间
|
|
449
|
+
log(`用户已扫码,等待确认(超时 ${scannedTimeout}s)...`);
|
|
450
|
+
// 不返回!继续 SSE 连接等待 bound 事件
|
|
451
|
+
// 在控制台显示提示(非 verbose 模式也显示)
|
|
452
|
+
console.log(`\n✅ 用户已扫码!请在 ${scannedTimeout} 秒内完成确认...\n`);
|
|
453
|
+
}
|
|
454
|
+
if (data.status === "expired") {
|
|
455
|
+
const reason = data.reason === "confirm_timeout" ? "确认超时" : data.reason === "timeout" ? "操作超时" : "二维码过期";
|
|
456
|
+
log(`绑定过期: ${reason}`);
|
|
457
|
+
// 返回包含错误信息的对象
|
|
458
|
+
return { status: "expired", reason: data.reason || "qr_expired", message: reason };
|
|
459
|
+
}
|
|
460
|
+
} catch (e) {
|
|
461
|
+
log(`解析 SSE 消息失败: ${e.message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// 连接关闭但没有收到最终状态
|
|
468
|
+
log(`SSE 连接异常关闭,未收到最终状态`);
|
|
469
|
+
return null;
|
|
470
|
+
} catch (e) {
|
|
471
|
+
log(`SSE 连接失败: ${e.message},降级为轮询模式...`);
|
|
472
|
+
|
|
473
|
+
// 降级:短轮询模式(重试 maxRetries 次)
|
|
474
|
+
const url = `${apiBase}/api/v1/binding/${bindingId}/result`;
|
|
475
|
+
|
|
476
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
477
|
+
if (verbose) {
|
|
478
|
+
process.stdout.write(` ⏳ 等待用户扫码绑定... (${i + 1}/${maxRetries})\r`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
const resp = await fetch(url);
|
|
483
|
+
if (!resp.ok) {
|
|
484
|
+
const text = await resp.text();
|
|
485
|
+
throw new Error(`轮询失败 (${resp.status}): ${text}`);
|
|
486
|
+
}
|
|
487
|
+
const data = await resp.json();
|
|
488
|
+
|
|
489
|
+
if (data.status === "bound") {
|
|
490
|
+
process.stdout.write("\x1b[K");
|
|
491
|
+
return data;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (data.status === "scanned") {
|
|
495
|
+
process.stdout.write("\x1b[K");
|
|
496
|
+
return { status: "scanned", timeout_seconds: SCANNED_CONFIRM_TIMEOUT_SEC };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
if (data.status === "expired") {
|
|
500
|
+
process.stdout.write("\x1b[K");
|
|
501
|
+
return { status: "expired", reason: data.reason || "qr_expired", message: "二维码过期" };
|
|
502
|
+
}
|
|
503
|
+
} catch (err) {
|
|
504
|
+
if (i === maxRetries - 1) throw err;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
process.stdout.write("\x1b[K");
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 在终端显示二维码
|
|
517
|
+
* @param {string} qrcodeUrl - 二维码 URL
|
|
518
|
+
* @param {boolean} attempt - 重试次数
|
|
519
|
+
*/
|
|
520
|
+
async function displayQR(qrcodeUrl, attempt = 1) {
|
|
521
|
+
// 重试时清屏,覆盖之前的二维码
|
|
522
|
+
if (attempt > 1) {
|
|
523
|
+
// ANSI 转义序列:清屏并移动光标到左上角
|
|
524
|
+
process.stdout.write('\x1b[2J\x1b[H');
|
|
525
|
+
warn(`二维码已过期,请扫描新的二维码(${attempt}/3)`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
const QRCode = await import("qrcode");
|
|
530
|
+
console.log("请使用云加客户端扫描二维码绑定机器人身份");
|
|
531
|
+
console.log();
|
|
532
|
+
const qr = await QRCode.toString(qrcodeUrl, { type: "terminal", small: true, margin: 1 });
|
|
533
|
+
console.log(qr);
|
|
534
|
+
console.log(`或者使用云加客户端访问该地址进行绑定`);
|
|
535
|
+
console.log(`${qrcodeUrl}`);
|
|
536
|
+
return;
|
|
537
|
+
} catch {}
|
|
538
|
+
|
|
539
|
+
// qrcode 模块未安装,尝试系统 CLI
|
|
540
|
+
const qrBin = which("qrcode");
|
|
541
|
+
if (qrBin) {
|
|
542
|
+
try {
|
|
543
|
+
const out = run(`echo '${qrcodeUrl.replace(/'/g, "'\\''")}' | qrcode -t ANSIUTF8 -m 2`, { silent: true });
|
|
544
|
+
console.log(out);
|
|
545
|
+
return;
|
|
546
|
+
} catch {}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 最终兜底:打印 URL
|
|
550
|
+
console.log();
|
|
551
|
+
console.log("使用云加客户端访问以下地址绑定");
|
|
552
|
+
console.log();
|
|
553
|
+
console.log(` 🔗 ${qrcodeUrl}`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ── 环境检查 ─────────────────────────────────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 检查 OpenClaw 是否已安装,返回版本号或 null。
|
|
560
|
+
*/
|
|
561
|
+
function checkOpenclaw() {
|
|
562
|
+
// 首先尝试运行 openclaw --version,如果成功则表示已安装
|
|
563
|
+
const ver = getOpenclawVersion();
|
|
564
|
+
if (ver) return ver;
|
|
565
|
+
// 如果失败,再尝试 which 查找
|
|
566
|
+
if (!which("openclaw")) {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
return getOpenclawVersion();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* 检查 openclaw-inspur-yunjia 插件是否已安装。
|
|
574
|
+
*/
|
|
575
|
+
function checkPlugin() {
|
|
576
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), ".openclaw");
|
|
577
|
+
const extDir = path.join(stateDir, "extensions");
|
|
578
|
+
|
|
579
|
+
// 支持多个可能的目录名
|
|
580
|
+
const possibleDirs = [
|
|
581
|
+
"openclaw-inspur-yunjia", // 当前配置中的 channel key
|
|
582
|
+
"inspur-yunjia", // 插件安装的实际目录名
|
|
583
|
+
];
|
|
584
|
+
|
|
585
|
+
for (const dir of possibleDirs) {
|
|
586
|
+
const pluginDir = path.join(extDir, dir);
|
|
587
|
+
if (fs.existsSync(pluginDir)) {
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* 获取已安装插件版本号(从 package.json 读取)。
|
|
596
|
+
*/
|
|
597
|
+
function getPluginVersion() {
|
|
598
|
+
const stateDir = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), ".openclaw");
|
|
599
|
+
const extDir = path.join(stateDir, "extensions");
|
|
600
|
+
|
|
601
|
+
// 支持多个可能的目录名
|
|
602
|
+
const possibleDirs = [
|
|
603
|
+
"openclaw-inspur-yunjia",
|
|
604
|
+
"inspur-yunjia",
|
|
605
|
+
];
|
|
606
|
+
|
|
607
|
+
for (const dir of possibleDirs) {
|
|
608
|
+
const pkgPath = path.join(extDir, dir, "package.json");
|
|
609
|
+
try {
|
|
610
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
611
|
+
return pkg.version || "unknown";
|
|
612
|
+
} catch {}
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* 执行环境检查,打印摘要。返回 { openclawVersion, pluginOk, pluginVersion }。
|
|
619
|
+
* 缺失项会打印提示信息但不会退出,由调用方决定。
|
|
620
|
+
*/
|
|
621
|
+
function checkEnvironment({ silent = false } = {}) {
|
|
622
|
+
const results = {
|
|
623
|
+
openclawVersion: null,
|
|
624
|
+
openclawOk: false,
|
|
625
|
+
pluginOk: false,
|
|
626
|
+
pluginVersion: null,
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
// 检查 Node.js
|
|
630
|
+
const nodeVer = process.versions.node;
|
|
631
|
+
|
|
632
|
+
// 检查 OpenClaw
|
|
633
|
+
const ocVer = checkOpenclaw();
|
|
634
|
+
if (ocVer) {
|
|
635
|
+
results.openclawVersion = ocVer;
|
|
636
|
+
results.openclawOk = true;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// 检查插件
|
|
640
|
+
if (checkPlugin()) {
|
|
641
|
+
results.pluginOk = true;
|
|
642
|
+
results.pluginVersion = getPluginVersion();
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (silent) return results;
|
|
646
|
+
|
|
647
|
+
console.log();
|
|
648
|
+
console.log(" 🦞 环境检查");
|
|
649
|
+
console.log(` Node.js ${nodeVer.padEnd(24)}`);
|
|
650
|
+
console.log(` OpenClaw ${(results.openclawOk ? "✅ " + ocVer : "❌ 未安装").padEnd(27)}`);
|
|
651
|
+
console.log(` 云加插件 ${(results.pluginOk ? "✅ " + (results.pluginVersion || "已安装") : "❌ 未安装").padEnd(27)}`);
|
|
652
|
+
console.log();
|
|
653
|
+
|
|
654
|
+
return results;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* 打印环境缺失项的修复提示,返回 false 表示有缺失项。
|
|
659
|
+
*/
|
|
660
|
+
function printEnvironmentHints(results) {
|
|
661
|
+
const hints = [];
|
|
662
|
+
|
|
663
|
+
if (!results.openclawOk) {
|
|
664
|
+
hints.push([
|
|
665
|
+
"OpenClaw 未安装",
|
|
666
|
+
"请先安装 OpenClaw:",
|
|
667
|
+
" npm install -g openclaw",
|
|
668
|
+
" 详见 https://docs.openclaw.ai/install",
|
|
669
|
+
]);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
if (!results.pluginOk) {
|
|
673
|
+
hints.push([
|
|
674
|
+
"浪潮云加插件未安装",
|
|
675
|
+
"请先安装插件:",
|
|
676
|
+
` openclaw plugins install ${PLUGIN_SPEC}`,
|
|
677
|
+
"或使用本工具安装并扫码绑定:",
|
|
678
|
+
` npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install`,
|
|
679
|
+
]);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (hints.length > 0) {
|
|
683
|
+
for (const [title, ...lines] of hints) {
|
|
684
|
+
error(title);
|
|
685
|
+
for (const line of lines) console.log(` ${line}`);
|
|
686
|
+
console.log();
|
|
687
|
+
}
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── commands ─────────────────────────────────────────────────────────────────
|
|
695
|
+
|
|
696
|
+
async function install() {
|
|
697
|
+
const rl = createRL();
|
|
698
|
+
|
|
699
|
+
// ── 第一步:环境检查 ────────────────────────────────────────────
|
|
700
|
+
const env = checkEnvironment();
|
|
701
|
+
|
|
702
|
+
if (!env.openclawOk) {
|
|
703
|
+
error("OpenClaw 未安装,请先安装后再运行本工具。");
|
|
704
|
+
console.log(" npm install -g openclaw");
|
|
705
|
+
console.log(" 详见 https://docs.openclaw.ai/install");
|
|
706
|
+
console.log();
|
|
707
|
+
log("安装 OpenClaw 后,重新运行以下命令即可自动安装插件并扫码绑定:");
|
|
708
|
+
console.log(` npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install`);
|
|
709
|
+
rl.close(); process.exit(1);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// 检查版本兼容性
|
|
713
|
+
const compat = selectPluginTag(env.openclawVersion);
|
|
714
|
+
if (!compat) { rl.close(); process.exit(1); }
|
|
715
|
+
|
|
716
|
+
// 检查插件是否已安装,未安装则自动安装
|
|
717
|
+
if (!env.pluginOk) {
|
|
718
|
+
// 检查是否使用本地开发包
|
|
719
|
+
const devPackagePath = resolveDevPackagePath();
|
|
720
|
+
let pluginInstallSpec;
|
|
721
|
+
|
|
722
|
+
if (devPackagePath) {
|
|
723
|
+
pluginInstallSpec = devPackagePath;
|
|
724
|
+
logAlways(`正在从本地路径安装开发包...`);
|
|
725
|
+
log(`开发包路径: ${devPackagePath}`);
|
|
726
|
+
} else {
|
|
727
|
+
pluginInstallSpec = `${PLUGIN_SPEC}@${compat.distTag}`;
|
|
728
|
+
logAlways(`正在自动安装插件 ${pluginInstallSpec} (${compat.label})...`);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
const installOut = run(`${OPENCLAW_CMD} plugins install "${pluginInstallSpec}"`);
|
|
733
|
+
if (installOut) log(installOut);
|
|
734
|
+
} catch (installErr) {
|
|
735
|
+
error("插件安装失败:");
|
|
736
|
+
if (installErr.stderr) console.error(installErr.stderr);
|
|
737
|
+
console.log(` ${OPENCLAW_CMD} plugins install "${pluginInstallSpec}"`);
|
|
738
|
+
rl.close(); process.exit(1);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (!checkPlugin()) {
|
|
742
|
+
error("插件安装命令执行完毕,但未检测到插件目录。请检查 OpenClaw 配置。");
|
|
743
|
+
rl.close(); process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
ensureOpenclawSymlink();
|
|
747
|
+
const pluginVersion = getPluginVersion();
|
|
748
|
+
if (devPackagePath) {
|
|
749
|
+
success(`✅ 开发包安装成功 (路径: ${devPackagePath})`);
|
|
750
|
+
} else {
|
|
751
|
+
success(`✅ 插件安装成功 (v${pluginVersion})`);
|
|
752
|
+
}
|
|
753
|
+
console.log();
|
|
754
|
+
} else {
|
|
755
|
+
// 插件已安装,检查是否使用了 --dev-package
|
|
756
|
+
const devPackagePath = resolveDevPackagePath();
|
|
757
|
+
if (devPackagePath) {
|
|
758
|
+
warn(`插件已安装,但 --dev-package 指定了不同的开发包路径。`);
|
|
759
|
+
log(`当前插件版本: ${env.pluginVersion}`);
|
|
760
|
+
log(`指定的开发包: ${devPackagePath}`);
|
|
761
|
+
console.log();
|
|
762
|
+
const rl2 = createRL();
|
|
763
|
+
const reinstall = await prompt(rl2, "是否重新安装开发包?(y/N): ");
|
|
764
|
+
rl2.close();
|
|
765
|
+
if (reinstall.toLowerCase() === "y") {
|
|
766
|
+
try {
|
|
767
|
+
// 先卸载现有插件
|
|
768
|
+
log("正在卸载现有插件...");
|
|
769
|
+
run(`${OPENCLAW_CMD} plugins uninstall ${PLUGIN_SPEC}`, { silent: true });
|
|
770
|
+
// 安装开发包
|
|
771
|
+
log(`正在安装开发包: ${devPackagePath}`);
|
|
772
|
+
run(`${OPENCLAW_CMD} plugins install "${devPackagePath}"`);
|
|
773
|
+
ensureOpenclawSymlink();
|
|
774
|
+
success(`✅ 开发包安装成功`);
|
|
775
|
+
} catch (err) {
|
|
776
|
+
error(`安装失败: ${err.message}`);
|
|
777
|
+
rl.close(); process.exit(1);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
success(`✅ 环境就绪 (OpenClaw ${env.openclawVersion}, 云加插件 ${env.pluginVersion})`);
|
|
782
|
+
console.log();
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── 第二步:扫码绑定(重试 3 次) ──────────────────────────────
|
|
786
|
+
// 用户绑定成功前,重试 3 次,每次重试重新获取并展示二维码,重试结束后放弃本次绑定
|
|
787
|
+
|
|
788
|
+
const apiBase = resolveBindingApiBase();
|
|
789
|
+
log(`绑定服务地址: ${apiBase}`);
|
|
790
|
+
|
|
791
|
+
const MAX_RETRY = MAX_POLL_RETRIES;
|
|
792
|
+
let bindResult = null;
|
|
793
|
+
let bindingId = null;
|
|
794
|
+
|
|
795
|
+
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
|
796
|
+
console.log();
|
|
797
|
+
|
|
798
|
+
log(`${new Date().toISOString()} 正在请求二维码...(第 ${attempt}/${MAX_RETRY} 次)`);
|
|
799
|
+
let qrResult;
|
|
800
|
+
try {
|
|
801
|
+
qrResult = await requestQRCode(apiBase);
|
|
802
|
+
} catch (e) {
|
|
803
|
+
error(`请求二维码失败: ${e.message}`);
|
|
804
|
+
if (attempt < MAX_RETRY) {
|
|
805
|
+
warn(`将在 3 秒后重试...`);
|
|
806
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
error("多次请求二维码失败,请检查绑定服务是否可用。");
|
|
810
|
+
console.log(" 也可以使用手动模式添加账户:");
|
|
811
|
+
console.log(" npx -y @inspur-ccs/openclaw-inspur-yunjia-cli add-account");
|
|
812
|
+
rl.close(); process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
bindingId = qrResult.binding_id;
|
|
816
|
+
log(`已获取二维码,绑定ID: ${bindingId},有效期 ${QR_EXPIRE_SECONDS} 秒`);
|
|
817
|
+
|
|
818
|
+
// 展示二维码(重试时覆盖旧的)
|
|
819
|
+
await displayQR(qrResult.qrcode_url, attempt);
|
|
820
|
+
|
|
821
|
+
// 轮询等待用户绑定(内部会处理 scanned 和 bound 事件)
|
|
822
|
+
log(`等待用户扫码(${QR_EXPIRE_SECONDS}秒有效)...`);
|
|
823
|
+
try {
|
|
824
|
+
bindResult = await pollBindingResult(apiBase, bindingId);
|
|
825
|
+
} catch (e) {
|
|
826
|
+
error(`轮询绑定结果时发生错误: ${e.message}`);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// 检查结果
|
|
830
|
+
if (!bindResult) {
|
|
831
|
+
// 连接错误或没有返回值,继续重试
|
|
832
|
+
continue;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (bindResult.status === "bound") {
|
|
836
|
+
// 绑定成功,退出循环
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (bindResult.status === "expired") {
|
|
841
|
+
// 过期处理
|
|
842
|
+
if (bindResult.reason === "confirm_timeout") {
|
|
843
|
+
// 用户扫码后确认超时,这是最终失败,不再重试
|
|
844
|
+
console.log();
|
|
845
|
+
error(`❌ 用户确认超时!`);
|
|
846
|
+
console.log(` 用户已扫码但未在 ${SCANNED_CONFIRM_TIMEOUT_SEC} 秒内完成确认。`);
|
|
847
|
+
console.log();
|
|
848
|
+
console.log(" 请重新运行安装命令:");
|
|
849
|
+
console.log(" npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install");
|
|
850
|
+
rl.close();
|
|
851
|
+
process.exit(1);
|
|
852
|
+
} else {
|
|
853
|
+
// 二维码过期
|
|
854
|
+
if (attempt < MAX_RETRY) {
|
|
855
|
+
// 还有重试机会
|
|
856
|
+
warn(`⏱️ 二维码已过期,正在生成新的二维码...(${attempt}/${MAX_RETRY})`);
|
|
857
|
+
continue;
|
|
858
|
+
} else {
|
|
859
|
+
// 最后一次重试也过期了
|
|
860
|
+
console.log();
|
|
861
|
+
error(`❌ 二维码已过期!`);
|
|
862
|
+
console.log(` 已尝试 ${MAX_RETRY} 次扫码绑定,均超时。`);
|
|
863
|
+
console.log();
|
|
864
|
+
console.log(" 请重新运行安装命令:");
|
|
865
|
+
console.log(" npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install");
|
|
866
|
+
rl.close();
|
|
867
|
+
process.exit(1);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// 其他状态继续等待(比如 scanned)
|
|
873
|
+
// 但 pollBindingResult 内部已经处理了 scanned,不会返回
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
rl.close();
|
|
877
|
+
|
|
878
|
+
if (!bindResult || bindResult.status !== "bound") {
|
|
879
|
+
error(`绑定失败,放弃本次绑定。`);
|
|
880
|
+
console.log(" 请稍后重新运行: npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install");
|
|
881
|
+
process.exit(1);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// 9. 绑定成功 — 写入配置(时序图步骤 10)
|
|
885
|
+
const { bot_id, bot_secret, user_id, user_name, enterprise, chat_base_url } = bindResult;
|
|
886
|
+
console.log();
|
|
887
|
+
success("✅ 用户绑定成功!");
|
|
888
|
+
console.log();
|
|
889
|
+
log(` 👤 用户: ${user_name || user_id}`);
|
|
890
|
+
if (enterprise) log(` 🏢 企业: ${enterprise}`);
|
|
891
|
+
log(` 🤖 Bot ID: ${bot_id}`);
|
|
892
|
+
|
|
893
|
+
log("正在写入通道配置...");
|
|
894
|
+
addAccountFromBinding({ botId: bot_id, botSecret: bot_secret, userId: user_id, userName: user_name, enterprise, baseChatUrl: chat_base_url });
|
|
895
|
+
|
|
896
|
+
// 10. 重启 Gateway(时序图步骤 11)
|
|
897
|
+
logAlways("正在重启 OpenClaw Gateway...");
|
|
898
|
+
try {
|
|
899
|
+
run(`${OPENCLAW_CMD} gateway restart`, { silent: false });
|
|
900
|
+
} catch {
|
|
901
|
+
error("重启失败,可手动执行:");
|
|
902
|
+
console.log(` ${OPENCLAW_CMD} gateway restart`);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
console.log();
|
|
906
|
+
success("🎉 安装完成!浪潮云加通道已就绪。");
|
|
907
|
+
console.log();
|
|
908
|
+
console.log(" 插件首次启动时会自动创建到用户的聊天频道并发送欢迎消息。");
|
|
909
|
+
console.log(" 查看通道状态: openclaw channels list");
|
|
910
|
+
console.log(" 添加更多账户: npx -y @inspur-ccs/openclaw-inspur-yunjia-cli add-account");
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/**
|
|
914
|
+
* 手动添加账户模式(不走二维码流程,直接输入 botId/botSecret)
|
|
915
|
+
*/
|
|
916
|
+
async function addAccount() {
|
|
917
|
+
const rl = createRL();
|
|
918
|
+
|
|
919
|
+
// 环境检查
|
|
920
|
+
const env = checkEnvironment();
|
|
921
|
+
|
|
922
|
+
if (!env.openclawOk) {
|
|
923
|
+
error("OpenClaw 未安装,请先安装后再运行本工具。");
|
|
924
|
+
console.log(" npm install -g openclaw");
|
|
925
|
+
console.log(" 详见 https://docs.openclaw.ai/install");
|
|
926
|
+
rl.close(); process.exit(1);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (!env.pluginOk) {
|
|
930
|
+
error("浪潮云加插件尚未安装,请先运行:");
|
|
931
|
+
console.log(` openclaw plugins install ${PLUGIN_SPEC}`);
|
|
932
|
+
console.log(" 或使用: npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install");
|
|
933
|
+
rl.close(); process.exit(1);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
success(`环境就绪: OpenClaw ${env.openclawVersion}, 插件 v${env.pluginVersion}`);
|
|
937
|
+
console.log();
|
|
938
|
+
|
|
939
|
+
console.log();
|
|
940
|
+
log("手动添加浪潮云加账户:");
|
|
941
|
+
console.log();
|
|
942
|
+
|
|
943
|
+
const botId = await prompt(rl, " 🤖 Bot ID: ");
|
|
944
|
+
if (!botId) { error("Bot ID 不能为空"); rl.close(); process.exit(1); }
|
|
945
|
+
|
|
946
|
+
const botSecret = await prompt(rl, " 🔑 Bot Secret: ");
|
|
947
|
+
if (!botSecret) { error("Bot Secret 不能为空"); rl.close(); process.exit(1); }
|
|
948
|
+
|
|
949
|
+
const enterprise = await prompt(rl, " 🏢 企业名称/ID (可选,回车跳过): ") || undefined;
|
|
950
|
+
const accountId = (await prompt(rl, " 📋 账户 ID (可选,默认 'default'): ")) || "default";
|
|
951
|
+
|
|
952
|
+
rl.close();
|
|
953
|
+
|
|
954
|
+
addAccountFromBinding({ botId, botSecret, enterprise });
|
|
955
|
+
success(`✅ 账户 "${accountId}" 添加成功!`);
|
|
956
|
+
|
|
957
|
+
console.log();
|
|
958
|
+
logAlways("正在重启 OpenClaw Gateway...");
|
|
959
|
+
try {
|
|
960
|
+
run(`${OPENCLAW_CMD} gateway restart`, { silent: false });
|
|
961
|
+
} catch {
|
|
962
|
+
error("重启失败,可手动执行:");
|
|
963
|
+
console.log(` ${OPENCLAW_CMD} gateway restart`);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
function help() {
|
|
968
|
+
console.log(`
|
|
969
|
+
用法: npx -y @inspur-ccs/openclaw-inspur-yunjia-cli [选项] <命令>
|
|
970
|
+
|
|
971
|
+
选项:
|
|
972
|
+
-v, --verbose 显示详细日志输出
|
|
973
|
+
--dev-package <path> 使用本地开发包安装插件(仅用于 install 命令)
|
|
974
|
+
--server <url> 指定绑定服务地址
|
|
975
|
+
--api-base <url> 指定绑定服务地址(同 --server)
|
|
976
|
+
|
|
977
|
+
命令:
|
|
978
|
+
check 检查当前环境是否满足运行要求
|
|
979
|
+
install 安装插件并通过二维码扫码绑定云加账户
|
|
980
|
+
add-account 手动添加账户(直接输入 Bot ID / Bot Secret)
|
|
981
|
+
help 显示帮助信息
|
|
982
|
+
|
|
983
|
+
兼容矩阵:`);
|
|
984
|
+
for (const e of COMPAT_MATRIX) {
|
|
985
|
+
console.log(` ${e.label.padEnd(28)} OpenClaw ${formatRange(e.openclawRange)}`);
|
|
986
|
+
}
|
|
987
|
+
console.log(`
|
|
988
|
+
安装流程(二维码扫码绑定):
|
|
989
|
+
0. 环境检查(OpenClaw + 插件)
|
|
990
|
+
1. 向云加服务端请求二维码(含绑定ID,60秒有效)
|
|
991
|
+
2. 显示二维码,等待用户使用云加客户端扫码
|
|
992
|
+
3. 用户在云加客户端确认绑定
|
|
993
|
+
4. 服务端返回 bot 凭据和绑定用户信息
|
|
994
|
+
5. 写入配置并重启 Gateway
|
|
995
|
+
6. 插件首次启动时创建聊天频道并发送欢迎消息
|
|
996
|
+
|
|
997
|
+
示例:
|
|
998
|
+
# 检查环境
|
|
999
|
+
npx -y @inspur-ccs/openclaw-inspur-yunjia-cli check
|
|
1000
|
+
|
|
1001
|
+
# 首次安装(二维码扫码)
|
|
1002
|
+
npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install
|
|
1003
|
+
|
|
1004
|
+
# 使用本地开发包安装
|
|
1005
|
+
npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install --dev-package ../openclaw-inspur-yunjia
|
|
1006
|
+
|
|
1007
|
+
# 指定绑定服务地址
|
|
1008
|
+
npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install --server http://localhost:3210
|
|
1009
|
+
|
|
1010
|
+
# 组合使用(开发包 + 本地服务)
|
|
1011
|
+
npx -y @inspur-ccs/openclaw-inspur-yunjia-cli install --dev-package ../openclaw-inspur-yunjia --server http://localhost:3210
|
|
1012
|
+
|
|
1013
|
+
# 手动添加账户
|
|
1014
|
+
npx -y @inspur-ccs/openclaw-inspur-yunjia-cli add-account
|
|
1015
|
+
`);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ── main ─────────────────────────────────────────────────────────────────────
|
|
1019
|
+
|
|
1020
|
+
const command = cleanArgv[0];
|
|
1021
|
+
|
|
1022
|
+
switch (command) {
|
|
1023
|
+
case "check":
|
|
1024
|
+
{
|
|
1025
|
+
const env = checkEnvironment();
|
|
1026
|
+
const ok = printEnvironmentHints(env);
|
|
1027
|
+
process.exit(ok ? 0 : 1);
|
|
1028
|
+
}
|
|
1029
|
+
break;
|
|
1030
|
+
case "install":
|
|
1031
|
+
install().catch((e) => { error(e.message); process.exit(1); });
|
|
1032
|
+
break;
|
|
1033
|
+
case "add-account":
|
|
1034
|
+
addAccount().catch((e) => { error(e.message); process.exit(1); });
|
|
1035
|
+
break;
|
|
1036
|
+
case "help":
|
|
1037
|
+
case "--help":
|
|
1038
|
+
case "-h":
|
|
1039
|
+
help();
|
|
1040
|
+
break;
|
|
1041
|
+
default:
|
|
1042
|
+
if (command) error(`未知命令: ${command}`);
|
|
1043
|
+
help();
|
|
1044
|
+
process.exit(command ? 1 : 0);
|
|
1045
|
+
}
|