connelio 0.1.0 → 0.1.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 +6 -3
- package/bin/connelio.mjs +89 -14
- package/package.json +1 -1
- package/src/hermes.mjs +57 -11
- package/src/i18n.mjs +68 -0
- package/src/pair.mjs +45 -17
- package/src/progress.mjs +52 -0
- package/src/qr.mjs +46 -0
package/README.md
CHANGED
|
@@ -42,11 +42,14 @@ Tokens are stored in `~/.connelio/agents/` with file mode 600. `forget` removes
|
|
|
42
42
|
One command on the computer that runs Hermes:
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
|
-
npx connelio connect --hermes
|
|
46
|
-
npx connelio connect --hermes --hermes-profile connelio # keep your Telegram bot, add Connelio as its own profile
|
|
45
|
+
npx connelio connect --hermes
|
|
47
46
|
```
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
That's the whole thing — it never asks you to choose between commands. If the Hermes on this computer already talks to a Telegram bot, Connelio gets its own Hermes profile (`connelio`) and leaves that bot alone; if a `connelio` profile is already there, it is reused. Only `--replace` swaps the bot your current profile uses. To put Connelio on a specific profile instead: `--hermes-profile <name>`.
|
|
49
|
+
|
|
50
|
+
Each step prints as it happens — checking Hermes, preparing the config, waiting for your phone, connected, restarting Hermes — and a failed step says what to do next. The QR code is drawn large (two characters per module, quiet zone around it, and the pairing code on its own line below), and falls back to the compact version only when the terminal is too narrow for it.
|
|
51
|
+
|
|
52
|
+
Scan the QR code, and the command writes Hermes's settings (address, token, and an allow-list with only you) through `hermes config set`, then restarts the Hermes gateway (`hermes gateway ensure` when it is not running yet).
|
|
50
53
|
|
|
51
54
|
Doing it by hand: Hermes talks to Telegram through python-telegram-bot and lets you set the API address. Create a Bot Agent in Connelio, copy its token once, then in your Hermes config:
|
|
52
55
|
|
package/bin/connelio.mjs
CHANGED
|
@@ -7,7 +7,9 @@ import { pair } from '../src/pair.mjs';
|
|
|
7
7
|
import { runAgent } from '../src/runner.mjs';
|
|
8
8
|
import { claudeCodeAdapter } from '../src/adapters/claude-code.mjs';
|
|
9
9
|
import { commandAdapter } from '../src/adapters/command.mjs';
|
|
10
|
-
import { configureHermes, hermesCli } from '../src/hermes.mjs';
|
|
10
|
+
import { configureHermes, hermesCli, ISOLATED_PROFILE, isConnelioBase } from '../src/hermes.mjs';
|
|
11
|
+
import { createProgress } from '../src/progress.mjs';
|
|
12
|
+
import { copy as copyFor, detectLang } from '../src/i18n.mjs';
|
|
11
13
|
|
|
12
14
|
const HELP = `connelio — put your AI agent in Connelio
|
|
13
15
|
|
|
@@ -25,8 +27,11 @@ Options for connect:
|
|
|
25
27
|
--knowledge <path> read-only folder that public chats may read
|
|
26
28
|
--name <text> how this computer is shown on your phone
|
|
27
29
|
--server <url> Connelio server (default https://connelio.app)
|
|
28
|
-
--hermes-profile <name> with --hermes: use (or create) this Hermes profile instead of
|
|
30
|
+
--hermes-profile <name> with --hermes: use (or create) this Hermes profile instead of choosing one for you
|
|
29
31
|
--replace with --hermes: replace the Telegram bot this Hermes profile already uses
|
|
32
|
+
|
|
33
|
+
With --hermes, if the Hermes on this computer already talks to a Telegram bot, Connelio
|
|
34
|
+
gets its own Hermes profile (${ISOLATED_PROFILE}) and leaves that bot alone. No questions asked.
|
|
30
35
|
`;
|
|
31
36
|
|
|
32
37
|
function parse(argv) {
|
|
@@ -46,6 +51,9 @@ function parse(argv) {
|
|
|
46
51
|
return { verb, flags, positional, command };
|
|
47
52
|
}
|
|
48
53
|
|
|
54
|
+
const flagOn = (value) => value === true || value === 'true' || value === '1';
|
|
55
|
+
const firstLine = (message) => String(message || '').split('\n').map((l) => l.trim()).filter(Boolean)[0] || '';
|
|
56
|
+
|
|
49
57
|
async function adapterFor(config) {
|
|
50
58
|
return config.command?.length ? commandAdapter(config.command) : claudeCodeAdapter();
|
|
51
59
|
}
|
|
@@ -61,24 +69,91 @@ async function start(config) {
|
|
|
61
69
|
saveAgent(config);
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
/** connect --hermes:一步不停,不再让用户在两行命令里选。 */
|
|
73
|
+
async function connectHermes({ server, flags, out = process.stdout }) {
|
|
74
|
+
const text = copyFor(detectLang());
|
|
75
|
+
const progress = createProgress(out);
|
|
76
|
+
const replace = flagOn(flags.replace);
|
|
77
|
+
const explicit = typeof flags['hermes-profile'] === 'string' ? flags['hermes-profile'] : '';
|
|
78
|
+
const deviceName = typeof flags.name === 'string' ? flags.name : undefined;
|
|
79
|
+
|
|
80
|
+
// 1 检查 Hermes
|
|
81
|
+
if (!hermesCli('').available()) {
|
|
82
|
+
progress.fail(text.steps.check, text.errors.noHermes.why, text.errors.noHermes.next);
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
progress.ok(text.steps.check);
|
|
86
|
+
|
|
87
|
+
// 2 建配置:已有别的 Telegram 机器人就自动开独立 profile(原来的不受影响),只有 --replace 才替换
|
|
88
|
+
let profile = explicit;
|
|
89
|
+
let notice = '';
|
|
90
|
+
if (!explicit && !replace) {
|
|
91
|
+
const state = hermesCli('').telegramState();
|
|
92
|
+
if (state.token && !isConnelioBase(state.baseUrl, server)) profile = ISOLATED_PROFILE;
|
|
93
|
+
else if (state.token) notice = text.alreadyConnelio;
|
|
94
|
+
}
|
|
95
|
+
if (explicit && !replace) {
|
|
96
|
+
const state = hermesCli(explicit).telegramState();
|
|
97
|
+
if (state.token && !isConnelioBase(state.baseUrl, server)) {
|
|
98
|
+
progress.fail(text.steps.prepare, text.errors.profileHasBot.why(explicit), text.errors.profileHasBot.next);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const hermes = hermesCli(profile);
|
|
103
|
+
let prepared;
|
|
104
|
+
try {
|
|
105
|
+
prepared = hermes.ensureProfile();
|
|
106
|
+
} catch (error) {
|
|
107
|
+
progress.fail(text.steps.prepare, text.errors.profileFailed.why, text.errors.profileFailed.next);
|
|
108
|
+
progress.note(firstLine(error.message));
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
progress.ok(text.steps.prepare);
|
|
112
|
+
if (profile === ISOLATED_PROFILE && !explicit) progress.note(prepared.existed ? text.reuseProfile : text.createProfile);
|
|
113
|
+
else if (replace) progress.note(text.keepActive);
|
|
114
|
+
if (notice) progress.note(notice);
|
|
115
|
+
|
|
116
|
+
// 3 二维码 + 等手机扫码
|
|
117
|
+
progress.waiting(text.steps.scan);
|
|
118
|
+
let result;
|
|
119
|
+
try {
|
|
120
|
+
result = await pair({ server, kind: 'hermes', deviceName, out });
|
|
121
|
+
} catch (error) {
|
|
122
|
+
const known = text.errors[error.code];
|
|
123
|
+
progress.fail(text.steps.scan, known?.why || text.errors.network.why, known?.next || text.errors.network.next);
|
|
124
|
+
if (!known && error.message) progress.note(firstLine(error.message));
|
|
125
|
+
return 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 4 写配置 = 已连接
|
|
129
|
+
try {
|
|
130
|
+
configureHermes({ hermes, server, token: result.token, ownerTelegramId: result.owner?.telegramId });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
progress.fail(text.steps.connected, text.errors.configWrite.why, text.errors.configWrite.next);
|
|
133
|
+
progress.note(firstLine(error.message));
|
|
134
|
+
return 1;
|
|
135
|
+
}
|
|
136
|
+
const bot = result.bot ?? {};
|
|
137
|
+
progress.ok(`${text.steps.connected}${bot.displayName ? ` — ${bot.displayName}${bot.username ? ` (@${bot.username})` : ''}` : ''}`);
|
|
138
|
+
|
|
139
|
+
// 5 重启 Hermes
|
|
140
|
+
const startCommand = `hermes${profile ? ` -p ${profile}` : ''} gateway run`;
|
|
141
|
+
progress.begin(text.steps.restart);
|
|
142
|
+
if (hermes.restart()) progress.ok(text.steps.restart);
|
|
143
|
+
else progress.warn(text.steps.restart, `${text.manualStart} ${startCommand}`);
|
|
144
|
+
|
|
145
|
+
progress.blank();
|
|
146
|
+
out.write(` ${text.done}\n`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
64
150
|
async function main() {
|
|
65
151
|
const { verb, flags, positional, command } = parse(process.argv.slice(2));
|
|
66
152
|
if (!verb || verb === 'help' || flags.help || flags.h) { process.stdout.write(HELP); return; }
|
|
67
153
|
const server = String(flags.server || process.env.CONNELIO_SERVER || 'https://connelio.app');
|
|
68
154
|
|
|
69
155
|
if (verb === 'connect' && flags.hermes) {
|
|
70
|
-
|
|
71
|
-
const hermes = hermesCli(profile);
|
|
72
|
-
if (!hermes.available()) throw new Error('Hermes was not found on this computer (`hermes --version` failed). Install Hermes first: https://hermes-agent.nousresearch.com');
|
|
73
|
-
hermes.ensureProfile();
|
|
74
|
-
if (hermes.existingTelegramToken() && !flags.replace) {
|
|
75
|
-
throw new Error(`This Hermes profile already talks to a Telegram bot. Keep it and add Connelio as a separate profile:\n npx connelio connect --hermes --hermes-profile connelio\nor replace it:\n npx connelio connect --hermes --replace`);
|
|
76
|
-
}
|
|
77
|
-
const result = await pair({ server, kind: 'hermes', deviceName: typeof flags.name === 'string' ? flags.name : undefined });
|
|
78
|
-
configureHermes({ hermes, server, token: result.token, ownerTelegramId: result.owner?.telegramId });
|
|
79
|
-
process.stdout.write(`✓ Hermes is now connected as ${result.bot.displayName} (@${result.bot.username}).\n`);
|
|
80
|
-
if (hermes.restart()) process.stdout.write('✓ Hermes gateway restarted. Say hi to it in Connelio.\n');
|
|
81
|
-
else process.stdout.write(`Start Hermes to go online: hermes${profile ? ` -p ${profile}` : ''} gateway run\n`);
|
|
156
|
+
process.exitCode = await connectHermes({ server, flags });
|
|
82
157
|
return;
|
|
83
158
|
}
|
|
84
159
|
if (verb === 'connect') {
|
package/package.json
CHANGED
package/src/hermes.mjs
CHANGED
|
@@ -1,35 +1,81 @@
|
|
|
1
1
|
// Hermes 一键接入:配对拿到 token 后,用 Hermes 自己的 `hermes config set` 把 Telegram 通道指向 Connelio。
|
|
2
2
|
// Hermes 走 python-telegram-bot,原生支持 platforms.telegram.extra.base_url —— 不需要给 Hermes 装任何插件。
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** 独立 profile 名字:已有 Telegram 机器人时,Connelio 用它自己的配置,互不影响。 */
|
|
9
|
+
export const ISOLATED_PROFILE = 'connelio';
|
|
4
10
|
|
|
5
11
|
export function hermesCli(profile) {
|
|
6
12
|
const bin = process.env.CONNELIO_HERMES_BIN || 'hermes';
|
|
7
13
|
const prefix = profile ? ['-p', profile] : [];
|
|
8
|
-
const run = (args) => spawnSync(bin, [...prefix, ...args], { encoding: 'utf8' });
|
|
9
|
-
|
|
14
|
+
const run = (args) => spawnSync(bin, [...prefix, ...args], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
15
|
+
const cli = {
|
|
16
|
+
profile,
|
|
10
17
|
available() {
|
|
11
|
-
const r = spawnSync(bin, ['--version'], { encoding: 'utf8' });
|
|
18
|
+
const r = spawnSync(bin, ['--version'], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
12
19
|
return !r.error && r.status === 0;
|
|
13
20
|
},
|
|
14
|
-
/** 这个 profile
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
/** 这个 profile 名是否已经存在(`hermes profile list` 第一列) */
|
|
22
|
+
profileExists() {
|
|
23
|
+
if (!profile) return true;
|
|
24
|
+
const r = spawnSync(bin, ['profile', 'list'], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
25
|
+
if (r.error || r.status !== 0) return existsSync(join(homedir(), '.hermes', 'profiles', profile));
|
|
26
|
+
return r.stdout.split('\n')
|
|
27
|
+
.map((line) => line.replace(/[─━│]/g, ' ').trim().split(/\s+/)[0] || '')
|
|
28
|
+
.map((name) => name.replace(/^[^\w.-]+/, ''))
|
|
29
|
+
.includes(profile);
|
|
30
|
+
},
|
|
31
|
+
/** 读一个配置项;没设置返回空串(Hermes 会打“Config key not set”并退出 1) */
|
|
32
|
+
get(key) {
|
|
33
|
+
const r = run(['config', 'get', key]);
|
|
34
|
+
if (r.error || r.status !== 0) return '';
|
|
17
35
|
const value = (r.stdout || '').trim();
|
|
18
|
-
return
|
|
36
|
+
return /^config key not set/i.test(value) ? '' : value;
|
|
37
|
+
},
|
|
38
|
+
/** 当前 profile 的 Telegram 状况:是否已有机器人、指向哪里 */
|
|
39
|
+
telegramState() {
|
|
40
|
+
const token = cli.get('TELEGRAM_BOT_TOKEN');
|
|
41
|
+
const baseUrl = cli.get('platforms.telegram.extra.base_url');
|
|
42
|
+
return { token, baseUrl, enabled: /^(true|1|yes)$/i.test(cli.get('platforms.telegram.enabled')) };
|
|
19
43
|
},
|
|
44
|
+
/** 建 profile(--clone:不带原来的机器人 token);已存在就复用 */
|
|
20
45
|
ensureProfile() {
|
|
21
|
-
if (!profile) return;
|
|
22
|
-
|
|
46
|
+
if (!profile) return { existed: true, created: false };
|
|
47
|
+
if (cli.profileExists()) return { existed: true, created: false };
|
|
48
|
+
const r = spawnSync(bin, ['profile', 'create', profile, '--clone'], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1' } });
|
|
49
|
+
if (r.error || r.status !== 0) throw new Error((r.stderr || r.error?.message || `hermes profile create exited ${r.status}`).trim());
|
|
50
|
+
return { existed: false, created: true };
|
|
23
51
|
},
|
|
24
52
|
set(key, value) {
|
|
25
53
|
const r = run(['config', 'set', key, String(value)]);
|
|
26
54
|
if (r.error || r.status !== 0) throw new Error(`hermes config set ${key} failed: ${(r.stderr || r.error?.message || '').trim()}`);
|
|
27
55
|
},
|
|
28
|
-
|
|
29
|
-
|
|
56
|
+
/** 网关现在是否在跑(`gateway status` 没跑时也退出 0,只能看文字) */
|
|
57
|
+
gatewayRunning() {
|
|
58
|
+
const r = run(['gateway', 'status']);
|
|
59
|
+
return !r.error && r.status === 0 && !/not running/i.test(`${r.stdout || ''}${r.stderr || ''}`);
|
|
60
|
+
},
|
|
61
|
+
/** 让新配置生效:在跑就重启;没跑就拉起来(ensure 起本地运行时,不装服务)。
|
|
62
|
+
* 真机上这两条都要几十秒,给足时间但不许无限等。 */
|
|
63
|
+
restart({ timeout = 150_000 } = {}) {
|
|
64
|
+
const r = spawnSync(bin, [...prefix, 'gateway', cli.gatewayRunning() ? 'restart' : 'ensure'], { encoding: 'utf8', timeout, env: { ...process.env, NO_COLOR: '1' } });
|
|
30
65
|
return !r.error && r.status === 0;
|
|
31
66
|
},
|
|
32
67
|
};
|
|
68
|
+
return cli;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 同一台 Connelio 服务器的配对地址(已经接着 Connelio 时不必再开独立 profile) */
|
|
72
|
+
export function isConnelioBase(baseUrl, server) {
|
|
73
|
+
if (!baseUrl) return false;
|
|
74
|
+
try {
|
|
75
|
+
const configured = new URL(baseUrl);
|
|
76
|
+
const target = new URL(server);
|
|
77
|
+
return configured.origin === target.origin && /\/tg\/bot\/?$/.test(configured.pathname);
|
|
78
|
+
} catch { return false; }
|
|
33
79
|
}
|
|
34
80
|
|
|
35
81
|
export function configureHermes({ hermes, server, token, ownerTelegramId }) {
|
package/src/i18n.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// 命令行文字:跟系统语言走(中文系统出中文,其余出英文)。只影响终端里的提示,不影响协议。
|
|
2
|
+
export function detectLang(env = process.env, intlLocale) {
|
|
3
|
+
const raw = String(env.CONNELIO_LANG || env.LC_ALL || env.LC_MESSAGES || env.LANG || '').toLowerCase();
|
|
4
|
+
const tag = raw || String(intlLocale || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().locale : '') || 'en').toLowerCase();
|
|
5
|
+
if (tag.startsWith('zh')) return 'zh';
|
|
6
|
+
return 'en';
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const COPY = {
|
|
10
|
+
zh: {
|
|
11
|
+
steps: { check: '检查 Hermes', prepare: '建配置', scan: '等手机扫码…', connected: '已连接', restart: '重启 Hermes' },
|
|
12
|
+
createProfile: '已为 Connelio 单独建了一个 Hermes 配置,原来的 Telegram 不受影响。',
|
|
13
|
+
reuseProfile: '这台电脑上已有 connelio 配置,接着用它,原来的 Telegram 不受影响。',
|
|
14
|
+
keepActive: '用的就是当前这个 Hermes 配置(--replace)。',
|
|
15
|
+
alreadyConnelio: '这个 Hermes 已经接着 Connelio,这次会换成新的配对。',
|
|
16
|
+
scanHint: '手机上打开 Connelio:消息 → 右上 + → 接入 Agent,扫上面的码。',
|
|
17
|
+
codeLabel: '配对码',
|
|
18
|
+
manualStart: 'Hermes 没在后台运行,手动启动一次:',
|
|
19
|
+
done: '好了,在手机 Connelio 里给它发条消息试试。',
|
|
20
|
+
errors: {
|
|
21
|
+
noHermes: { why: '这台电脑上没找到 Hermes。', next: '先安装 Hermes(https://hermes-agent.nousresearch.com),装好再跑一次。' },
|
|
22
|
+
profileHasBot: {
|
|
23
|
+
why: (name) => `配置 ${name} 已经接着一个 Telegram 机器人。`,
|
|
24
|
+
next: '想保留它:去掉 --hermes-profile,让 Connelio 自己建配置;确实要替换:加 --replace。',
|
|
25
|
+
},
|
|
26
|
+
profileFailed: { why: '新建 Hermes 配置失败。', next: '跑 hermes profile create connelio --clone 看具体报错,修好再重试。' },
|
|
27
|
+
'start-failed': { why: '连不上 Connelio 服务器。', next: '检查网络;换服务器用 --server https://…,然后重试。' },
|
|
28
|
+
declined: { why: '手机上取消了配对。', next: '想接的话再跑一次:npx connelio connect --hermes' },
|
|
29
|
+
expired: { why: '配对码过期了。', next: '再跑一次,重新出码。' },
|
|
30
|
+
claimed: { why: '这个配对码已经被用过了。', next: '再跑一次,重新出码。' },
|
|
31
|
+
cancelled: { why: '已取消。', next: '重新跑一次即可。' },
|
|
32
|
+
notSupported: { why: '这台服务器还没开配对功能。', next: '升级 Connelio 服务端,或用 --server 指向已开通的服务器。' },
|
|
33
|
+
network: { why: '和服务器通信中断了。', next: '网络恢复后再跑一次。' },
|
|
34
|
+
configWrite: { why: '写入 Hermes 配置失败。', next: '看上面那行报错;修好后重跑本命令。' },
|
|
35
|
+
},
|
|
36
|
+
retry: '再跑一次:npx connelio connect --hermes',
|
|
37
|
+
},
|
|
38
|
+
en: {
|
|
39
|
+
steps: { check: 'Checking Hermes', prepare: 'Preparing config', scan: 'Waiting for your phone…', connected: 'Connected', restart: 'Restarting Hermes' },
|
|
40
|
+
createProfile: 'Connelio got its own Hermes profile. Your existing Telegram bot is untouched.',
|
|
41
|
+
reuseProfile: 'Reusing the connelio profile on this computer. Your existing Telegram bot is untouched.',
|
|
42
|
+
keepActive: 'Using this Hermes profile (--replace).',
|
|
43
|
+
alreadyConnelio: 'This Hermes is already connected to Connelio — this run replaces the pairing.',
|
|
44
|
+
scanHint: 'On your phone open Connelio: Chats → + → Connect an Agent, then scan the code above.',
|
|
45
|
+
codeLabel: 'Pairing code',
|
|
46
|
+
manualStart: 'Hermes is not running in the background. Start it once:',
|
|
47
|
+
done: 'Done. Send it a message from Connelio on your phone.',
|
|
48
|
+
errors: {
|
|
49
|
+
noHermes: { why: 'Hermes was not found on this computer.', next: 'Install Hermes (https://hermes-agent.nousresearch.com), then run this again.' },
|
|
50
|
+
profileHasBot: {
|
|
51
|
+
why: (name) => `The ${name} profile already talks to a Telegram bot.`,
|
|
52
|
+
next: 'To keep it, drop --hermes-profile and let Connelio make its own profile; to replace it, add --replace.',
|
|
53
|
+
},
|
|
54
|
+
profileFailed: { why: 'Could not create the Hermes profile.', next: 'Run hermes profile create connelio --clone to see the error, fix it, then retry.' },
|
|
55
|
+
'start-failed': { why: 'Could not reach the Connelio server.', next: 'Check your network; use --server https://… for another server, then retry.' },
|
|
56
|
+
declined: { why: 'Pairing was declined on the phone.', next: 'Run it again when you want to connect: npx connelio connect --hermes' },
|
|
57
|
+
expired: { why: 'The pairing code expired.', next: 'Run it again for a fresh code.' },
|
|
58
|
+
claimed: { why: 'That pairing code was already used.', next: 'Run it again for a fresh code.' },
|
|
59
|
+
cancelled: { why: 'Cancelled.', next: 'Just run it again.' },
|
|
60
|
+
notSupported: { why: 'This server does not have pairing enabled.', next: 'Upgrade the Connelio server, or point --server at one that has it.' },
|
|
61
|
+
network: { why: 'Lost contact with the server.', next: 'Run it again when the network is back.' },
|
|
62
|
+
configWrite: { why: 'Writing the Hermes settings failed.', next: 'Read the error line above, fix it, then run this again.' },
|
|
63
|
+
},
|
|
64
|
+
retry: 'Run it again: npx connelio connect --hermes',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const copy = (lang) => COPY[lang] || COPY.en;
|
package/src/pair.mjs
CHANGED
|
@@ -1,32 +1,60 @@
|
|
|
1
1
|
// 设备码配对:终端出二维码 + 8 位码 → 手机确认 → 一次性领 token。
|
|
2
|
-
import QRCode from 'qrcode';
|
|
3
2
|
import { hostname } from 'node:os';
|
|
3
|
+
import { fitsWide, renderQr } from './qr.mjs';
|
|
4
|
+
import { copy as copyFor, detectLang } from './i18n.mjs';
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
+
export class PairError extends Error {
|
|
7
|
+
constructor(code, message) {
|
|
8
|
+
super(message || code);
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const BOLD = '\x1b[1m';
|
|
14
|
+
const RESET = '\x1b[0m';
|
|
15
|
+
const bold = (text) => `${BOLD}${text}${RESET}`;
|
|
16
|
+
|
|
17
|
+
export async function pair({ server, kind, deviceName = prettyHost(), out = process.stdout, signal, lang = detectLang(), columns = out.columns, tty = out.isTTY }) {
|
|
18
|
+
const text = copyFor(lang);
|
|
6
19
|
const base = server.replace(/\/+$/, '');
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
20
|
+
let started;
|
|
21
|
+
try {
|
|
22
|
+
started = await fetch(`${base}/api/agent-pair/start`, { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
23
|
+
body: JSON.stringify({ deviceName, kind }), signal });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.name === 'AbortError') throw new PairError('cancelled');
|
|
26
|
+
throw new PairError('start-failed', error?.message);
|
|
27
|
+
}
|
|
28
|
+
if (started.status === 404) throw new PairError('notSupported');
|
|
29
|
+
if (!started.ok) throw new PairError('start-failed', `HTTP ${started.status}`);
|
|
11
30
|
const pairing = await started.json();
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
out.write(
|
|
16
|
-
out.write(`
|
|
31
|
+
|
|
32
|
+
// 大码优先:每格两字符,放不进终端才退小码
|
|
33
|
+
const big = fitsWide(pairing.qr, columns);
|
|
34
|
+
out.write(`\n${renderQr(pairing.qr, { small: !big, indent: 2 })}\n\n`);
|
|
35
|
+
out.write(` ${text.scanHint}\n\n`);
|
|
36
|
+
const strong = tty ? bold : (value) => value;
|
|
37
|
+
out.write(` ${strong(text.codeLabel)} ${strong(pairing.code)}\n\n`);
|
|
38
|
+
|
|
17
39
|
const deadline = Date.now() + pairing.expiresInSeconds * 1000;
|
|
40
|
+
let dropped = 0; // 连续连不上就别死等:网络断了要立刻说人话
|
|
18
41
|
while (Date.now() < deadline) {
|
|
19
|
-
if (signal?.aborted) throw new
|
|
42
|
+
if (signal?.aborted) throw new PairError('cancelled');
|
|
20
43
|
const response = await fetch(`${base}/api/agent-pair/poll`, { method: 'POST', headers: { 'content-type': 'application/json' },
|
|
21
44
|
body: JSON.stringify({ pairingId: pairing.pairingId, pollSecret: pairing.pollSecret, timeout: 20 }), signal }).catch(() => null);
|
|
22
|
-
if (!response) {
|
|
45
|
+
if (!response) {
|
|
46
|
+
if (++dropped >= 3) throw new PairError('network');
|
|
47
|
+
await sleep(2000);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
dropped = 0;
|
|
23
51
|
const result = await response.json().catch(() => ({}));
|
|
24
52
|
if (result.status === 'approved') return result;
|
|
25
|
-
if (result.status === 'denied') throw new
|
|
26
|
-
if (result.status === 'expired' || response.status === 404) throw new
|
|
27
|
-
if (result.status === 'claimed') throw new
|
|
53
|
+
if (result.status === 'denied') throw new PairError('declined');
|
|
54
|
+
if (result.status === 'expired' || response.status === 404) throw new PairError('expired');
|
|
55
|
+
if (result.status === 'claimed') throw new PairError('claimed');
|
|
28
56
|
}
|
|
29
|
-
throw new
|
|
57
|
+
throw new PairError('expired');
|
|
30
58
|
}
|
|
31
59
|
|
|
32
60
|
function prettyHost() {
|
package/src/progress.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// 每一步一行:终端里先显示「… 这一步」,做完原地变成「✓ 这一步」;
|
|
2
|
+
// 输出被重定向到文件时只留结果行,日志照样看得懂。
|
|
3
|
+
export function createProgress(out = process.stdout, { tty = Boolean(out.isTTY) } = {}) {
|
|
4
|
+
let open = false;
|
|
5
|
+
const close = () => { if (open) { if (tty) out.write('\r\x1b[2K'); open = false; } };
|
|
6
|
+
return {
|
|
7
|
+
starts: () => tty,
|
|
8
|
+
/** 开始一步(可打印「…」),fn 返回后收尾成 ✓;fn 抛错就只写 ✗ 交给调用方 */
|
|
9
|
+
async step(label, fn) {
|
|
10
|
+
if (tty) { close(); out.write(` … ${label}`); open = true; }
|
|
11
|
+
try {
|
|
12
|
+
const value = await fn();
|
|
13
|
+
close();
|
|
14
|
+
out.write(` ✓ ${label}\n`);
|
|
15
|
+
return value;
|
|
16
|
+
} catch (error) {
|
|
17
|
+
close();
|
|
18
|
+
throw error;
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
/** 耗时又不确定多久的一步:先把「…」打出来(重定向到日志也打),做完再收尾 */
|
|
22
|
+
begin(label) {
|
|
23
|
+
close();
|
|
24
|
+
out.write(` … ${label}\n`);
|
|
25
|
+
},
|
|
26
|
+
/** 长时间等待(等手机扫码):这行要一直留着 */
|
|
27
|
+
waiting(label) {
|
|
28
|
+
close();
|
|
29
|
+
if (tty) { out.write(` … ${label}`); open = true; } else out.write(` … ${label}\n`);
|
|
30
|
+
},
|
|
31
|
+
ok(label) {
|
|
32
|
+
close();
|
|
33
|
+
out.write(` ✓ ${label}\n`);
|
|
34
|
+
},
|
|
35
|
+
fail(label, why, next) {
|
|
36
|
+
close();
|
|
37
|
+
out.write(` ✗ ${label}\n`);
|
|
38
|
+
if (why) out.write(` ${why}\n`);
|
|
39
|
+
if (next) out.write(` → ${next}\n`);
|
|
40
|
+
},
|
|
41
|
+
warn(label, next) {
|
|
42
|
+
close();
|
|
43
|
+
out.write(` ! ${label}\n`);
|
|
44
|
+
if (next) out.write(` → ${next}\n`);
|
|
45
|
+
},
|
|
46
|
+
note(line) {
|
|
47
|
+
close();
|
|
48
|
+
if (line) out.write(` ${line}\n`);
|
|
49
|
+
},
|
|
50
|
+
blank() { close(); out.write('\n'); },
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/qr.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 终端二维码:每格两字符(大码,手机好扫),四周留白两格;终端太窄才退回半高小码。
|
|
2
|
+
// 自己按矩阵画,不依赖 ANSI 背景色 —— 复制粘贴到别处也不会变成乱码。
|
|
3
|
+
import QRCode from 'qrcode';
|
|
4
|
+
|
|
5
|
+
export const MARGIN = 2;
|
|
6
|
+
|
|
7
|
+
function matrixOf(text, errorCorrectionLevel) {
|
|
8
|
+
const { modules } = QRCode.create(text, { errorCorrectionLevel });
|
|
9
|
+
return { size: modules.size, dark: (x, y) => x >= 0 && y >= 0 && x < modules.size && y < modules.size && Boolean(modules.get(x, y)) };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 大码宽度(含留白),用于判断终端放不放得下 */
|
|
13
|
+
export function wideWidth(text, { margin = MARGIN, errorCorrectionLevel = 'M' } = {}) {
|
|
14
|
+
return (matrixOf(text, errorCorrectionLevel).size + margin * 2) * 2;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 窄终端用:一格一字符、两行并一行(半块字符),宽度减半 */
|
|
18
|
+
export function fitsWide(text, columns, options) {
|
|
19
|
+
return !columns || wideWidth(text, options) <= columns;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function renderQr(text, { small = false, margin = MARGIN, errorCorrectionLevel = 'M', indent = 0 } = {}) {
|
|
23
|
+
const { size, dark } = matrixOf(text, errorCorrectionLevel);
|
|
24
|
+
const pad = ' '.repeat(indent);
|
|
25
|
+
const lines = [];
|
|
26
|
+
const from = -margin;
|
|
27
|
+
const to = size + margin;
|
|
28
|
+
if (!small) {
|
|
29
|
+
for (let y = from; y < to; y++) {
|
|
30
|
+
let line = '';
|
|
31
|
+
for (let x = from; x < to; x++) line += dark(x, y) ? '██' : ' ';
|
|
32
|
+
lines.push(pad + line);
|
|
33
|
+
}
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
for (let y = from; y < to; y += 2) {
|
|
37
|
+
let line = '';
|
|
38
|
+
for (let x = from; x < to; x++) {
|
|
39
|
+
const top = dark(x, y);
|
|
40
|
+
const bottom = dark(x, y + 1);
|
|
41
|
+
line += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' ';
|
|
42
|
+
}
|
|
43
|
+
lines.push(pad + line);
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|