@milian/linker-cli 0.0.0-stage → 0.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,242 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const { exec } = require('node:child_process');
5
+ const { login, applyPrefix } = require('../api');
6
+ const {
7
+ saveCredentials,
8
+ saveConfig,
9
+ normalizeBaseUrl,
10
+ isBaseUrlFromEnv
11
+ } = require('../config');
12
+ const { renderLoginPage, renderDonePage } = require('../login-page');
13
+
14
+ const MAX_BODY_BYTES = 1024 * 1024;
15
+
16
+ function readBody(req) {
17
+ return new Promise((resolve, reject) => {
18
+ let size = 0;
19
+ const chunks = [];
20
+ req.on('data', (chunk) => {
21
+ size += chunk.length;
22
+ if (size > MAX_BODY_BYTES) {
23
+ reject(new Error('请求体过大'));
24
+ req.destroy();
25
+ return;
26
+ }
27
+ chunks.push(chunk);
28
+ });
29
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
30
+ req.on('error', reject);
31
+ });
32
+ }
33
+
34
+ /**
35
+ * 尝试用系统默认浏览器打开登录页。
36
+ * disabled 为真时完全不尝试(--no-browser):适合由宿主拉起浏览器的场景。
37
+ * 打不开时必须明确告知用户手动访问,否则用户只会看到一行 URL 不知所措。
38
+ */
39
+ function tryOpenBrowser(url, disabled) {
40
+ if (disabled) return;
41
+ const platform = process.platform;
42
+ const cmd =
43
+ platform === 'darwin' ? `open "${url}"` : platform === 'win32' ? `start "" "${url}"` : `xdg-open "${url}"`;
44
+ exec(cmd, (err) => {
45
+ if (err) {
46
+ process.stderr.write(`无法自动打开浏览器,请手动访问:${url}\n`);
47
+ }
48
+ });
49
+ }
50
+
51
+ /**
52
+ * 起本地登录页,等待用户提交账密,调用原系统登录接口并落盘授权信息。
53
+ * 关键点:CLI 会打印 http://127.0.0.1:<port>/ 供 WorkBuddy 提取并拉起浏览器,
54
+ * 因此 cli.json 必须配置 "authWaitForExit": true,否则本进程会被提前 kill。
55
+ */
56
+ async function authLogin(cfg, options) {
57
+ const noBrowser = Boolean(options && options.noBrowser);
58
+ const loginSuffix = applyPrefix(cfg, cfg.loginPath);
59
+ // 环境变量优先级高于配置文件:被 LINKER_BASE_URL 锁定时,登录页不允许改地址,
60
+ // 否则用户改了也只在本次生效、下次运行又回到环境变量的值,属于静默不一致
61
+ const envLocked = isBaseUrlFromEnv(cfg);
62
+ const lockedReason = envLocked
63
+ ? `当前地址由环境变量 LINKER_BASE_URL 指定:${cfg.baseUrl}`
64
+ : '';
65
+
66
+ return new Promise((resolve, reject) => {
67
+ let settled = false;
68
+ let server = null;
69
+
70
+ const finish = (err, cred) => {
71
+ if (settled) return;
72
+ settled = true;
73
+ clearTimeout(timer);
74
+ if (server) {
75
+ // server.close() 只是「停止接受新连接」,已有连接会继续占用 event loop。
76
+ // 浏览器页面没被关掉时 keep-alive 连接会一直挂着,进程就退不出去 —— 必须主动断开。
77
+ try {
78
+ server.close();
79
+ } catch {
80
+ /* ignore */
81
+ }
82
+ if (typeof server.closeIdleConnections === 'function') {
83
+ try {
84
+ server.closeIdleConnections();
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ if (typeof server.closeAllConnections === 'function') {
90
+ try {
91
+ server.closeAllConnections();
92
+ } catch {
93
+ /* ignore */
94
+ }
95
+ }
96
+ }
97
+ if (err) reject(err);
98
+ else resolve(cred);
99
+ };
100
+
101
+ const timer = setTimeout(
102
+ () => finish(new Error(`登录等待超时(${Math.round(cfg.authTimeoutMs / 1000)} 秒)`)),
103
+ cfg.authTimeoutMs
104
+ );
105
+
106
+ server = http.createServer(async (req, res) => {
107
+ const url = new URL(req.url, 'http://127.0.0.1');
108
+
109
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/login')) {
110
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
111
+ res.end(
112
+ renderLoginPage({
113
+ baseUrl: cfg.baseUrl,
114
+ loginSuffix,
115
+ editable: !envLocked,
116
+ lockedReason
117
+ })
118
+ );
119
+ return;
120
+ }
121
+
122
+ if (req.method === 'POST' && url.pathname === '/login') {
123
+ let payload;
124
+ try {
125
+ const raw = await readBody(req);
126
+ payload = JSON.parse(raw || '{}');
127
+ } catch (err) {
128
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
129
+ res.end(JSON.stringify({ ok: false, message: '请求格式错误:' + err.message }));
130
+ return;
131
+ }
132
+
133
+ const username = String(payload.username || '').trim();
134
+ const password = String(payload.password || '');
135
+
136
+ if (!username || !password) {
137
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
138
+ res.end(JSON.stringify({ ok: false, message: '用户名和密码不能为空' }));
139
+ return;
140
+ }
141
+
142
+ // 平台地址:没有默认值,必须由用户在登录页填写。规范化后用于本次登录,成功后写回配置。
143
+ let loginBase = String(cfg.baseUrl || '').replace(/\/+$/, '');
144
+ if (!envLocked) {
145
+ const norm = normalizeBaseUrl(payload.baseUrl || cfg.baseUrl);
146
+ if (!norm.ok) {
147
+ const detail = {
148
+ empty: '请填写平台地址(形如 http://主机:端口)',
149
+ protocol: '只支持 http:// 或 https:// 开头的地址',
150
+ invalid: '平台地址格式不对(形如 http://主机:端口)'
151
+ }[norm.reason] || '平台地址不合法';
152
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
153
+ res.end(JSON.stringify({ ok: false, message: detail }));
154
+ return;
155
+ }
156
+ loginBase = norm.value;
157
+ }
158
+
159
+ try {
160
+ const cred = await login(cfg, username, password, loginBase);
161
+ saveCredentials(cred);
162
+
163
+ // 地址变了就写回配置,否则下一条业务命令仍会用旧地址(凭证却是新地址的,直接 401)
164
+ if (!envLocked && loginBase !== String(cfg.baseUrl || '').replace(/\/+$/, '')) {
165
+ saveConfig({ baseUrl: loginBase });
166
+ }
167
+
168
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
169
+ res.end(JSON.stringify({ ok: true, nickName: cred.nickName, baseUrl: cred.baseUrl }));
170
+
171
+ // 给浏览器一点时间渲染成功页,再退出进程让 WorkBuddy 感知完成
172
+ setTimeout(() => finish(null, cred), 600);
173
+ } catch (err) {
174
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
175
+ res.end(JSON.stringify({ ok: false, message: err.message }));
176
+ }
177
+ return;
178
+ }
179
+
180
+ if (req.method === 'GET' && url.pathname === '/done') {
181
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
182
+ res.end(renderDonePage(''));
183
+ return;
184
+ }
185
+
186
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
187
+ res.end('Not Found');
188
+ });
189
+
190
+ server.on('error', (err) => finish(err));
191
+
192
+ server.listen(0, '127.0.0.1', () => {
193
+ const { port } = server.address();
194
+ const loginUrl = `http://127.0.0.1:${port}/`;
195
+
196
+ // 输出格式必须严格:单独一行,前后有空白分隔,不能加引号或尖括号
197
+ process.stdout.write('\n登录地址:\n');
198
+ process.stdout.write(loginUrl + '\n');
199
+
200
+ // 走 stderr,避免干扰宿主对登录 URL 的提取
201
+ process.stderr.write(
202
+ `平台地址:${cfg.baseUrl || '(未配置)'}` +
203
+ (envLocked ? '(由环境变量锁定)' : cfg.baseUrl ? '(可在登录页修改)' : '(请在登录页填写)') +
204
+ '\n'
205
+ );
206
+
207
+ // 终端里直接运行时自行打开浏览器;被 WorkBuddy 调度时(无 TTY)交给宿主打开。
208
+ // 但非 TTY 也可能是「自己重定向到文件」,那种情况没人会打开,必须提示手动访问。
209
+ if (noBrowser) {
210
+ process.stderr.write(`已禁用自动打开浏览器,请手动访问:${loginUrl}\n`);
211
+ } else if (process.stdout.isTTY) {
212
+ tryOpenBrowser(loginUrl, false);
213
+ } else {
214
+ process.stderr.write(`非交互环境未自动打开浏览器,如未弹窗请手动访问:${loginUrl}\n`);
215
+ }
216
+ });
217
+ });
218
+ }
219
+
220
+ /**
221
+ * 纯终端交互登录,仅用于本地调试。WorkBuddy 环境下不会走这条路径(无 TTY)。
222
+ */
223
+ async function authLoginInteractive(cfg) {
224
+ const readline = require('node:readline');
225
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
226
+
227
+ const ask = (q) => new Promise((res) => rl.question(q, res));
228
+
229
+ try {
230
+ const username = (await ask('用户名: ')).trim();
231
+ const password = await ask('密码: ');
232
+ if (!username || !password) throw new Error('用户名和密码不能为空');
233
+
234
+ const cred = await login(cfg, username, password);
235
+ saveCredentials(cred);
236
+ return cred;
237
+ } finally {
238
+ rl.close();
239
+ }
240
+ }
241
+
242
+ module.exports = { authLogin, authLoginInteractive };
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ const { loadCredentials } = require('../config');
4
+ const { authedRequest } = require('../api');
5
+
6
+ const MAX_PAGE_SIZE = 10000;
7
+
8
+ /** 取全部分组(pageSize 固定 10000,实际不会超过) */
9
+ async function fetchGroups(cfg, cred) {
10
+ const res = await authedRequest(
11
+ cfg,
12
+ cred,
13
+ 'GET',
14
+ `/sys/api/v1/apigroups?pageNum=1&pageSize=${MAX_PAGE_SIZE}`
15
+ );
16
+ const json = res.json;
17
+ if (!json || json.code !== 200) {
18
+ throw new Error(`查询分组失败:code=${json && json.code} msg=${(json && json.msg) || '未知错误'}`);
19
+ }
20
+ const data = json.data || {};
21
+ const records = Array.isArray(data.records) ? data.records : [];
22
+ return { total: typeof data.total === 'number' ? data.total : records.length, records };
23
+ }
24
+
25
+ function dispWidth(s) {
26
+ let w = 0;
27
+ for (const ch of String(s == null ? '' : s)) {
28
+ w += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/.test(ch)
29
+ ? 2
30
+ : 1;
31
+ }
32
+ return w;
33
+ }
34
+
35
+ function pad(s, n) {
36
+ const str = String(s == null ? '' : s);
37
+ const w = dispWidth(str);
38
+ return w >= n ? str : str + ' '.repeat(n - w);
39
+ }
40
+
41
+ /**
42
+ * 查询 API 分组列表(groupPrefix 即创建连接器时用的"分组 code")
43
+ * GET /index/sys/api/v1/apigroups?pageNum=1&pageSize=10000
44
+ */
45
+ async function commandGroups(cfg, argv) {
46
+ const args = argv.slice();
47
+ let keyword = '';
48
+ let asJson = false;
49
+
50
+ for (let i = 0; i < args.length; i += 1) {
51
+ const a = args[i];
52
+ if (a === '--name' || a === '-n') {
53
+ keyword = args[i + 1] || '';
54
+ i += 1;
55
+ } else if (a === '--json') {
56
+ asJson = true;
57
+ } else if (!a.startsWith('-')) {
58
+ keyword = a;
59
+ }
60
+ }
61
+
62
+ const cred = loadCredentials();
63
+ if (!cred || !cred.tokenValue) {
64
+ throw new Error('未登录或授权已失效,请先执行 auth login');
65
+ }
66
+
67
+ const { total, records } = await fetchGroups(cfg, cred);
68
+
69
+ const kw = keyword.trim().toLowerCase();
70
+ const matched = kw
71
+ ? records.filter(
72
+ (r) =>
73
+ String(r.groupPrefix || '').toLowerCase().includes(kw) ||
74
+ String(r.groupName || '').toLowerCase().includes(kw)
75
+ )
76
+ : records;
77
+
78
+ if (asJson) {
79
+ process.stdout.write(JSON.stringify({ total, matched: matched.length, records: matched }, null, 2) + '\n');
80
+ process.exitCode = 0;
81
+ return;
82
+ }
83
+
84
+ process.stdout.write(`全部 ${total} 个分组,匹配 ${matched.length} 个${keyword ? `(关键词 "${keyword}")` : ''}\n\n`);
85
+ if (!matched.length) {
86
+ process.stdout.write('(无匹配结果)\n');
87
+ process.exitCode = 0;
88
+ return;
89
+ }
90
+
91
+ const header = ['#', 'groupPrefix', 'groupName', 'groupId', 'enabled'];
92
+ const rows = matched.map((r, i) => [
93
+ String(i + 1),
94
+ String(r.groupPrefix || ''),
95
+ String(r.groupName || ''),
96
+ String(r.groupId || ''),
97
+ String(r.enabled)
98
+ ]);
99
+ const widths = header.map((h, ci) => Math.max(dispWidth(h), ...rows.map((row) => dispWidth(row[ci]))));
100
+
101
+ process.stdout.write(header.map((h, i) => pad(h, widths[i])).join(' ') + '\n');
102
+ process.stdout.write(widths.map((w) => '-'.repeat(w)).join(' ') + '\n');
103
+ for (const row of rows) {
104
+ process.stdout.write(row.map((c, i) => pad(c, widths[i])).join(' ') + '\n');
105
+ }
106
+ process.exitCode = 0;
107
+ }
108
+
109
+ module.exports = { commandGroups, fetchGroups, MAX_PAGE_SIZE };