@jesonliu/lark-claudecode-bridge 1.0.5 → 1.0.6
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/assets/web/js/pages/larkcli-device.js +169 -63
- package/assets/web/js/pages/overview.js +29 -19
- package/dist/lark-cli-manager.d.ts +102 -1
- package/dist/lark-cli-manager.js +353 -12
- package/dist/lark-cli-manager.js.map +1 -1
- package/dist/web/server.js +16 -1
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// ============
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// ============ 飞书配置应用 / 扫码授权:页面内二维码弹窗 ============
|
|
2
|
+
// 与「拉终端」并列的第二条路径——把 lark-cli 的 config init 与 device flow 都搬到配置页里,
|
|
3
|
+
// 用户直接扫码完成,不用切窗口。它不经过终端探测,因此无桌面 / SSH 环境同样可用
|
|
4
|
+
// (那条路上「去终端」原本是无出口的死路)。
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// 同一个弹窗里两个阶段(phase),因为它们是**一条连续的路**:
|
|
7
|
+
// config:应用还没配置时,先扫码创建/绑定飞书应用(后端 spawn `config init --new` 并
|
|
8
|
+
// 从其输出里流式解析授权链接——官方 README 与内置 skill 规定的用法)
|
|
9
|
+
// auth :再扫码授权(设备流,三段式见 src/lark-cli-manager.ts 的设备流一节)
|
|
10
|
+
// 配置完成后**自动接续**授权,用户不需要关掉弹窗再来一次(后端配置会话置 done 前
|
|
11
|
+
// 已用 checkAuth() 复检过「确实配上了」,所以接续时设备流的前置守卫必然放行)。
|
|
10
12
|
//
|
|
11
13
|
// **device_code 全程只存后端内存,从不下发到浏览器。**
|
|
12
14
|
//
|
|
@@ -23,7 +25,12 @@ export function fmtRemain(sec) {
|
|
|
23
25
|
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/**
|
|
28
|
+
/** 纯函数(可测):与后端 needsLarkCliConfig 同源——detail 含 not_configured 即「未配置应用」 */
|
|
29
|
+
export function larkNotConfigured(r) {
|
|
30
|
+
return r?.auth?.state === 'unauthorized' && /not_configured/i.test(r?.auth?.detail || '');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 纯函数(可测):授权阶段终态 → 给用户看的一句话 */
|
|
27
34
|
export function deviceStateHint(state, error) {
|
|
28
35
|
if (state === 'done') return '✅ 授权完成,可以关闭本弹窗了。';
|
|
29
36
|
if (state === 'expired') return error || '二维码已过期,请重新生成。';
|
|
@@ -33,39 +40,60 @@ export function deviceStateHint(state, error) {
|
|
|
33
40
|
return '';
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
/** 纯函数(可测):配置阶段终态 → 给用户看的一句话 */
|
|
44
|
+
export function configStateHint(state, error) {
|
|
45
|
+
if (state === 'done') return '✅ 飞书应用已配置,接着申请授权二维码。';
|
|
46
|
+
if (state === 'expired') return error || '配置链接已失效,请重新生成二维码。';
|
|
47
|
+
// 配置这条路每一步都在浏览器里,失败原因多半是「没走完」——把两个出口都点出来
|
|
48
|
+
if (state === 'failed') return error || '配置未完成,可重新生成二维码再试,或改用终端窗口。';
|
|
49
|
+
if (state === 'none') return error || '配置会话已失效(桥接器可能已重启),请重新生成二维码。';
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const STATE_TAG = { done: 'ok', expired: 'warn', failed: 'warn', none: 'warn' };
|
|
54
|
+
const STATE_LABEL = {
|
|
55
|
+
config: { done: '已配置', expired: '已过期', failed: '未完成', none: '未完成' },
|
|
56
|
+
auth: { done: '已授权', expired: '已过期', failed: '未完成', none: '未完成' },
|
|
57
|
+
};
|
|
58
|
+
|
|
36
59
|
/**
|
|
37
|
-
*
|
|
60
|
+
* 打开飞书配置 / 扫码授权弹窗。未配置应用时自动进入 config 阶段,配完接续 auth 阶段。
|
|
38
61
|
* @param {object} opts
|
|
39
62
|
* @param {(r:any)=>void} [opts.onChange] 收到最新 /api/lark-cli 结果时回调,用于同步刷新概览行
|
|
40
|
-
* @param {()=>void} [opts.onTerminal]
|
|
63
|
+
* @param {()=>void} [opts.onTerminal] 授权阶段选「改用终端窗口」时回调
|
|
64
|
+
* @param {()=>void} [opts.onConfigTerminal] 配置阶段选「改用终端窗口」时回调
|
|
41
65
|
* @param {boolean} [opts.wasAuthorized] 打开时的授权态快照:区分「本来就好」与「本次扫码完成」
|
|
42
|
-
* @param {boolean} [opts.notConfigured] 应用未配置(not_configured
|
|
43
|
-
* @param {()=>void} [opts.onConfig] 用户选「打开配置向导」时回调
|
|
66
|
+
* @param {boolean} [opts.notConfigured] 应用未配置(not_configured):先走 config 阶段
|
|
44
67
|
*/
|
|
45
68
|
export function openLarkCliDeviceDialog({
|
|
46
|
-
onChange, onTerminal, wasAuthorized = false, notConfigured = false,
|
|
69
|
+
onChange, onTerminal, onConfigTerminal, wasAuthorized = false, notConfigured = false,
|
|
47
70
|
} = {}) {
|
|
48
71
|
const { mask, unmount } = mountDialog(`
|
|
49
72
|
<div class="modal dlg" role="dialog" aria-modal="true" style="width:min(520px,94vw)">
|
|
50
|
-
<h3 class="dlg-title">飞书授权(扫码完成)</h3>
|
|
51
|
-
<div class="dlg-msg" id="lqdLead"
|
|
52
|
-
<div id="lqdBody"><div class="hint"
|
|
73
|
+
<h3 class="dlg-title" id="lqdTitle">飞书授权(扫码完成)</h3>
|
|
74
|
+
<div class="dlg-msg" id="lqdLead"></div>
|
|
75
|
+
<div id="lqdBody"><div class="hint">正在向飞书申请二维码…</div></div>
|
|
53
76
|
<div class="dlg-foot">
|
|
54
77
|
<button class="btn" data-lqd="terminal">改用终端窗口</button>
|
|
55
78
|
<button class="btn" data-lqd="copy" hidden>复制链接</button>
|
|
56
79
|
<button class="btn" data-lqd="regen" hidden>重新生成二维码</button>
|
|
57
|
-
<button class="btn primary" data-lqd="config" hidden>打开配置向导</button>
|
|
58
80
|
<button class="btn primary" data-lqd="close">关闭</button>
|
|
59
81
|
</div>
|
|
60
82
|
</div>`);
|
|
61
83
|
|
|
84
|
+
const titleEl = mask.querySelector('#lqdTitle');
|
|
85
|
+
const leadEl = mask.querySelector('#lqdLead');
|
|
62
86
|
const body = mask.querySelector('#lqdBody');
|
|
63
87
|
const btnCopy = mask.querySelector('[data-lqd="copy"]');
|
|
64
88
|
const btnRegen = mask.querySelector('[data-lqd="regen"]');
|
|
65
|
-
const btnConfig = mask.querySelector('[data-lqd="config"]');
|
|
66
89
|
const btnTerminal = mask.querySelector('[data-lqd="terminal"]');
|
|
67
90
|
const btnClose = mask.querySelector('[data-lqd="close"]');
|
|
91
|
+
|
|
92
|
+
// needConfig 必须是可变绑定:配置完成后就地翻掉,doStart 才能接着走授权
|
|
93
|
+
let needConfig = !!notConfigured;
|
|
94
|
+
let phase = needConfig ? 'config' : 'auth';
|
|
68
95
|
let session = null; // 最近一次 start 的响应(存链接与二维码)
|
|
96
|
+
let painted = {}; // 已画出去的内容:相同内容不重画,免得二维码每隔 2s 闪一下
|
|
69
97
|
let baseLeft = 0; // 服务端给的剩余秒数
|
|
70
98
|
let baseAt = 0; // 收到该秒数时的本地时刻
|
|
71
99
|
let poll = null;
|
|
@@ -81,71 +109,90 @@ export function openLarkCliDeviceDialog({
|
|
|
81
109
|
// 一个一打开就已过期的二维码
|
|
82
110
|
const remainNow = () => Math.max(0, baseLeft - (Date.now() - baseAt) / 1000);
|
|
83
111
|
|
|
84
|
-
const
|
|
112
|
+
const setLead = (text) => { leadEl.textContent = text; };
|
|
113
|
+
const setTitle = (text) => { titleEl.textContent = text; };
|
|
114
|
+
|
|
115
|
+
const hideActions = () => { btnCopy.hidden = true; btnRegen.hidden = true; };
|
|
116
|
+
|
|
117
|
+
const paintQr = (s) => {
|
|
85
118
|
const qr = s.qrDataUrl
|
|
86
|
-
? `<div class="qr-box"><img src="${esc(s.qrDataUrl)}" alt="
|
|
119
|
+
? `<div class="qr-box"><img src="${esc(s.qrDataUrl)}" alt="飞书二维码" width="220" height="220"></div>`
|
|
87
120
|
: '<div class="hint" style="margin-top:8px">二维码生成失败,请复制下方链接在飞书中打开。</div>';
|
|
121
|
+
// 倒计时只在服务端给了有效期时才显示:配置流拿不到有效期,编一个数字只会误导用户
|
|
122
|
+
const life = typeof s.expiresInSec === 'number'
|
|
123
|
+
? `二维码 <b id="lqdLeft">${fmtRemain(remainNow())}</b> 后失效`
|
|
124
|
+
: '若二维码失效,点「重新生成二维码」即可';
|
|
88
125
|
body.innerHTML = qr
|
|
89
|
-
+ `<div class="hint" style="margin-top:8px"
|
|
126
|
+
+ `<div class="hint" style="margin-top:8px">${life}`
|
|
90
127
|
+ (s.userCode ? ` · 授权码 <span class="chip">${esc(s.userCode)}</span>` : '')
|
|
91
128
|
+ '</div>'
|
|
92
129
|
+ `<div class="hint qr-url">${esc(s.verificationUrl || '')}</div>`;
|
|
93
130
|
btnCopy.hidden = false;
|
|
94
131
|
btnRegen.hidden = false;
|
|
132
|
+
painted = { url: s.verificationUrl, qr: s.qrDataUrl };
|
|
95
133
|
};
|
|
96
134
|
|
|
97
135
|
const paintDone = (state, { identity, error } = {}) => {
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
body.innerHTML = `<div><span class="tag ${
|
|
136
|
+
const label = (STATE_LABEL[phase] || STATE_LABEL.auth)[state] || '未完成';
|
|
137
|
+
const hint = phase === 'config' ? configStateHint(state, error) : deviceStateHint(state, error);
|
|
138
|
+
body.innerHTML = `<div><span class="tag ${STATE_TAG[state] || 'warn'}">${label}</span>`
|
|
101
139
|
+ (identity ? ` <span class="chip">${esc(identity)}</span>` : '')
|
|
102
140
|
+ '</div>'
|
|
103
|
-
+ `<div class="hint" style="margin-top:8px">${esc(
|
|
141
|
+
+ `<div class="hint" style="margin-top:8px">${esc(hint)}</div>`;
|
|
104
142
|
btnCopy.hidden = true;
|
|
105
143
|
btnRegen.hidden = state === 'done';
|
|
106
144
|
};
|
|
107
145
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
* 所以连请求都不发——直接把「为什么发不了码」和「下一步去哪」摆出来。
|
|
111
|
-
* 「改用终端窗口」一并收起:那条路同样会撞上未配置守卫。
|
|
112
|
-
*/
|
|
113
|
-
const paintNeedConfig = () => {
|
|
146
|
+
/** 后端把前置问题写成了可照做的文案(未安装 / 探测不出 / 已配置),直接透出 */
|
|
147
|
+
const paintFail = (msg) => {
|
|
114
148
|
body.innerHTML = '<div><span class="tag warn">无法发起</span></div>'
|
|
115
|
-
+
|
|
116
|
-
+ '<div class="hint" style="margin-top:6px">点下方「打开配置向导」:向导会创建或绑定飞书应用,'
|
|
117
|
-
+ '完成后<b>自动接着走授权登录</b>,不用回到本页重来。</div>';
|
|
118
|
-
mask.querySelector('#lqdLead').textContent = '首次使用需先配置飞书应用,之后才能扫码授权。';
|
|
119
|
-
btnTerminal.hidden = true;
|
|
149
|
+
+ `<div class="hint" style="margin-top:8px">${esc(msg)}</div>`;
|
|
120
150
|
btnCopy.hidden = true;
|
|
121
|
-
btnRegen.hidden =
|
|
122
|
-
btnConfig.hidden = false;
|
|
123
|
-
btnClose.classList.remove('primary'); // 主按钮让位给「打开配置向导」
|
|
151
|
+
btnRegen.hidden = false; // 给出路:失败也要能重来,不留死胡同
|
|
124
152
|
};
|
|
125
153
|
|
|
126
154
|
/** 拉一次概览数据同步行内状态(拿不到就算了,别让弹窗崩) */
|
|
127
155
|
const syncRow = async () => {
|
|
128
|
-
if (!onChange) return;
|
|
156
|
+
if (!onChange) return null;
|
|
129
157
|
const cur = await api('GET', '/api/lark-cli').catch(() => null);
|
|
130
158
|
if (cur) onChange(cur);
|
|
131
159
|
return cur;
|
|
132
160
|
};
|
|
133
161
|
|
|
162
|
+
const startPolling = () => {
|
|
163
|
+
poll = setInterval(doPoll, POLL_MS);
|
|
164
|
+
tick = setInterval(() => {
|
|
165
|
+
const el = body.querySelector('#lqdLeft');
|
|
166
|
+
if (el) el.textContent = fmtRemain(remainNow());
|
|
167
|
+
}, TICK_MS);
|
|
168
|
+
};
|
|
169
|
+
|
|
134
170
|
const doPoll = async () => {
|
|
135
171
|
if (closed) return;
|
|
172
|
+
const endpoint = phase === 'config' ? '/api/lark-cli/config/status' : '/api/lark-cli/device/status';
|
|
136
173
|
let r;
|
|
137
174
|
try {
|
|
138
|
-
r = await api('GET',
|
|
175
|
+
r = await api('GET', endpoint);
|
|
139
176
|
} catch {
|
|
140
177
|
return; // 静默重试:桥接器重启时会短暂失联,弹错反而吓人
|
|
141
178
|
}
|
|
142
179
|
if (closed) return;
|
|
143
180
|
if (r.state === 'pending') {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
181
|
+
if (phase === 'auth') {
|
|
182
|
+
// 以服务端时间为准校正倒计时基准(每次轮询都对一次表)
|
|
183
|
+
baseLeft = r.expiresInSec ?? remainNow();
|
|
184
|
+
baseAt = Date.now();
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
// 配置流:链接与二维码要等子进程吐出来。start 若没等到就返回了,这里补画
|
|
188
|
+
// (内容没变就不重画——每 2s 重建一次 <img> 会让二维码闪)
|
|
189
|
+
if (r.verificationUrl && (painted.url !== r.verificationUrl || painted.qr !== r.qrDataUrl)) {
|
|
190
|
+
session = { ...(session || {}), ...r };
|
|
191
|
+
paintQr(session);
|
|
192
|
+
}
|
|
147
193
|
return;
|
|
148
194
|
}
|
|
195
|
+
if (phase === 'config') return finishConfigPhase(r);
|
|
149
196
|
stopTimers();
|
|
150
197
|
if (r.state === 'none') {
|
|
151
198
|
// 内存态被冲(桥接器重启),但用户可能已经扫完了:回查授权态兜底,
|
|
@@ -159,32 +206,88 @@ export function openLarkCliDeviceDialog({
|
|
|
159
206
|
paintDone(r.state, { identity: r.identity, error: r.error });
|
|
160
207
|
};
|
|
161
208
|
|
|
162
|
-
|
|
209
|
+
/**
|
|
210
|
+
* 配置完成 → 同一个弹窗里就地接续授权,兑现「完成后自动接着走授权登录」的承诺。
|
|
211
|
+
* 后端置 done 之前已用 checkAuth() 复检确认应用真的配上了,所以设备流的前置守卫必然放行。
|
|
212
|
+
*/
|
|
213
|
+
const continueToAuth = async () => {
|
|
214
|
+
phase = 'auth';
|
|
215
|
+
needConfig = false;
|
|
216
|
+
await syncRow(); // 概览行先翻成「已配置」
|
|
217
|
+
if (closed) return;
|
|
218
|
+
toast('✅ 飞书应用已配置,接着申请授权二维码');
|
|
219
|
+
await startAuth(false);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const finishConfigPhase = async (r) => {
|
|
223
|
+
stopTimers();
|
|
224
|
+
if (r.state === 'done') return continueToAuth();
|
|
225
|
+
if (r.state === 'none') {
|
|
226
|
+
// 内存态被冲(桥接器重启),但用户可能已经在浏览器里配完了:回查兜底
|
|
227
|
+
const cur = await syncRow();
|
|
228
|
+
if (cur && !larkNotConfigured(cur)) return continueToAuth();
|
|
229
|
+
}
|
|
230
|
+
return paintDone(r.state, { error: r.error });
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const startAuth = async (regenerate) => {
|
|
163
234
|
stopTimers();
|
|
164
|
-
|
|
235
|
+
setTitle('飞书授权(扫码完成)');
|
|
236
|
+
setLead('用飞书 App 扫描下方二维码,按提示确认授权。授权成功后本页会自动更新。');
|
|
165
237
|
body.innerHTML = '<div class="hint">正在向飞书申请授权二维码…</div>';
|
|
166
|
-
|
|
167
|
-
btnRegen.hidden = true;
|
|
238
|
+
hideActions();
|
|
168
239
|
try {
|
|
169
240
|
session = await api('POST', '/api/lark-cli/device/start', { regenerate: !!regenerate });
|
|
170
241
|
} catch (e) {
|
|
171
242
|
// 后端把「未安装」「应用未配置」等前置问题写成了可照做的文案,直接透出
|
|
172
|
-
|
|
173
|
-
+ `<div class="hint" style="margin-top:8px">${esc(e.message)}</div>`;
|
|
174
|
-
toast(e.message, true);
|
|
243
|
+
paintFail(e.message);
|
|
175
244
|
return;
|
|
176
245
|
}
|
|
177
246
|
if (closed) return;
|
|
178
247
|
baseLeft = session.expiresInSec ?? 0;
|
|
179
248
|
baseAt = Date.now();
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
249
|
+
painted = {};
|
|
250
|
+
paintQr(session);
|
|
251
|
+
startPolling();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const startConfig = async (regenerate) => {
|
|
255
|
+
stopTimers();
|
|
256
|
+
setTitle('飞书配置(扫码完成)');
|
|
257
|
+
setLead('用飞书 App 扫描下方二维码,按提示创建或绑定飞书应用。'
|
|
258
|
+
+ '完成后会自动接着申请授权二维码,不用回到本页重来。');
|
|
259
|
+
body.innerHTML = '<div class="hint">正在向飞书申请配置二维码…</div>';
|
|
260
|
+
hideActions();
|
|
261
|
+
try {
|
|
262
|
+
session = await api('POST', '/api/lark-cli/config/start', { regenerate: !!regenerate });
|
|
263
|
+
} catch (e) {
|
|
264
|
+
// 打开弹窗时的 notConfigured 快照可能已经过期(用户刚在别处配好了,后端据此拒绝)。
|
|
265
|
+
// 回查一次,确实已配置就直接接续授权,而不是把一个已经过时的错误甩给用户。
|
|
266
|
+
const cur = await syncRow();
|
|
267
|
+
if (cur && !larkNotConfigured(cur)) return continueToAuth();
|
|
268
|
+
paintFail(e.message);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (closed) return;
|
|
272
|
+
painted = {};
|
|
273
|
+
if (session.state === 'pending') {
|
|
274
|
+
if (session.verificationUrl) paintQr(session);
|
|
275
|
+
else paintWait(); // 链接要等子进程输出,交给轮询补画
|
|
276
|
+
startPolling();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
return paintDone(session.state, { error: session.error });
|
|
186
280
|
};
|
|
187
281
|
|
|
282
|
+
/** 配置链接还没吐出来时的过渡态(仍然给「重新生成」这条出路,不让人干等) */
|
|
283
|
+
const paintWait = () => {
|
|
284
|
+
body.innerHTML = '<div class="hint">正在向飞书申请配置二维码…(通常几秒内出现)</div>';
|
|
285
|
+
btnCopy.hidden = true;
|
|
286
|
+
btnRegen.hidden = false;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const doStart = (regenerate) => (phase === 'config' ? startConfig(regenerate) : startAuth(regenerate));
|
|
290
|
+
|
|
188
291
|
const close = () => {
|
|
189
292
|
closed = true;
|
|
190
293
|
stopTimers();
|
|
@@ -194,17 +297,20 @@ export function openLarkCliDeviceDialog({
|
|
|
194
297
|
const unbind = bindDialogKeys((k) => { if (k === 'Escape') close(); });
|
|
195
298
|
|
|
196
299
|
btnClose.onclick = close;
|
|
197
|
-
|
|
300
|
+
btnRegen.onclick = () => void doStart(true);
|
|
198
301
|
// 关弹窗**不动后端会话**(沿用终端弹窗「关弹窗不中断流程」的既有理念):
|
|
199
|
-
// 重新打开时 start 不带 regenerate →
|
|
200
|
-
btnTerminal.onclick = () => {
|
|
201
|
-
|
|
302
|
+
// 重新打开时 start 不带 regenerate → 后端幂等复用同一个会话,二维码原样回来
|
|
303
|
+
btnTerminal.onclick = () => {
|
|
304
|
+
const cb = phase === 'config' ? onConfigTerminal : onTerminal;
|
|
305
|
+
close();
|
|
306
|
+
cb?.();
|
|
307
|
+
};
|
|
202
308
|
btnCopy.onclick = async () => {
|
|
203
309
|
const url = session?.verificationUrl;
|
|
204
310
|
if (!url) return;
|
|
205
311
|
try {
|
|
206
312
|
await navigator.clipboard.writeText(url);
|
|
207
|
-
toast('
|
|
313
|
+
toast('链接已复制');
|
|
208
314
|
} catch {
|
|
209
315
|
toast('复制失败,请手动选中链接复制', true);
|
|
210
316
|
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// 桥接器启停 / 版本更新(宿主进程管理:embedded 页面随进程生死,独立页面经 PID 跨进程操作)
|
|
3
3
|
import { S, $, esc, toast, api, refresh } from '../core.js';
|
|
4
4
|
import { bindDialogKeys, confirmDialog, mountDialog, progressDialog } from '../ui.js';
|
|
5
|
-
|
|
5
|
+
// larkNotConfigured 与后端 needsLarkCliConfig 同源(detail 含 not_configured 即「未配置应用」),
|
|
6
|
+
// 判定只有一份、放在弹窗模块里,避免两处漂移
|
|
7
|
+
import { larkNotConfigured, openLarkCliDeviceDialog } from './larkcli-device.js';
|
|
6
8
|
|
|
7
9
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
8
10
|
const UPD = { checkedAt: 0, data: null };
|
|
@@ -138,11 +140,6 @@ async function runUpdateFlow() {
|
|
|
138
140
|
}
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
/** 纯函数(可测):与后端 needsLarkCliConfig 同源——detail 含 not_configured 即「未配置应用」 */
|
|
142
|
-
function larkCliNotConfigured(r) {
|
|
143
|
-
return r?.auth?.state === 'unauthorized' && /not_configured/i.test(r?.auth?.detail || '');
|
|
144
|
-
}
|
|
145
|
-
|
|
146
143
|
/** 纯函数(可测):概览表格行里「飞书 CLI」的状态 HTML(不含操作按钮,按钮由 render 按需挂) */
|
|
147
144
|
export function larkCliStatHtml(r) {
|
|
148
145
|
if (!r) return '<span class="hint">未检查</span>';
|
|
@@ -163,18 +160,29 @@ export function larkCliStatHtml(r) {
|
|
|
163
160
|
}
|
|
164
161
|
|
|
165
162
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
163
|
+
* 纯函数(可测):概览行主入口按钮的文案——按「点开后会先走哪一步」命名,
|
|
164
|
+
* 不出现「按钮写扫码授权、点开弹窗却是配置二维码」的错位。
|
|
165
|
+
* 未配置应用时弹窗从 config 阶段起步(见 openDeviceDialog 与 larkcli-device.js),故叫「配置应用」。
|
|
166
|
+
* 探测不出(unknown)**不**算未配置:误判会把已配好应用的用户反复推去重配,按「扫码授权」处理。
|
|
167
|
+
*/
|
|
168
|
+
export function larkCliPrimaryLabel(r) {
|
|
169
|
+
if (r?.auth?.state === 'authorized') return '重新授权';
|
|
170
|
+
return larkNotConfigured(r) ? '配置应用' : '扫码授权';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 扫码弹窗(配置 + 授权的主路径)。未配置应用时它从**配置阶段**起步:页面内直接出示
|
|
175
|
+
* 配置二维码,用户扫完在浏览器里建好应用,弹窗自动接续出示授权二维码——全程不切窗口,
|
|
176
|
+
* 无桌面 / SSH 环境下同样走得通(那条路上「去终端向导」原本是无出口的死路)。
|
|
177
|
+
* 两个阶段的「改用终端窗口」都接回既有终端链路:那条路一行没改,保留补跑命令的能力。
|
|
168
178
|
*/
|
|
169
179
|
function openDeviceDialog() {
|
|
170
180
|
openLarkCliDeviceDialog({
|
|
171
181
|
wasAuthorized: LARKCLI.data?.auth?.state === 'authorized',
|
|
172
|
-
|
|
173
|
-
// 交给弹窗直接讲明白,而不是让用户点一次、收一个红色的失败
|
|
174
|
-
notConfigured: larkCliNotConfigured(LARKCLI.data),
|
|
182
|
+
notConfigured: larkNotConfigured(LARKCLI.data),
|
|
175
183
|
onChange: (r) => { LARKCLI.data = r; LARKCLI.checkedAt = Date.now(); renderLarkCliResult(); },
|
|
176
184
|
onTerminal: () => void larkCliActionFlow('auth'),
|
|
177
|
-
|
|
185
|
+
onConfigTerminal: () => void larkCliActionFlow('config'),
|
|
178
186
|
});
|
|
179
187
|
}
|
|
180
188
|
|
|
@@ -195,13 +203,13 @@ function renderLarkCliResult() {
|
|
|
195
203
|
};
|
|
196
204
|
if (!r.installed) { add('安装', 'install'); return; }
|
|
197
205
|
if (r.hasUpdate) add('更新', 'update');
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
206
|
+
// 入口常驻,且已授权也给(token 会过期,随时可重走一遍 device flow 刷新)。
|
|
207
|
+
// 走**页面内二维码**(主路径,无桌面环境同样可用);终端窗口降级为弹窗里的备选。
|
|
208
|
+
// 文案跟着「弹窗会先走哪一步」走(见 larkCliPrimaryLabel):未配置应用时弹窗从配置阶段
|
|
209
|
+
// 起步、先出配置二维码,按钮就叫「配置应用」;已配置才是「扫码授权」。
|
|
210
|
+
// **入口数量不变**——三种状态都是同一个弹窗(op 一律 auth-device),只是弹窗按前置条件
|
|
211
|
+
// 决定先出哪种二维码(见 openDeviceDialog 与 larkcli-device.js)。
|
|
212
|
+
add(larkCliPrimaryLabel(r), 'auth-device');
|
|
205
213
|
if (r.skill && !r.skill.installed) add('装 SKILL', 'skill');
|
|
206
214
|
}
|
|
207
215
|
|
|
@@ -337,6 +345,8 @@ async function larkCliActionFlow(op = 'install') {
|
|
|
337
345
|
},
|
|
338
346
|
config: {
|
|
339
347
|
what: '配置应用',
|
|
348
|
+
// 这是**备选**路径:主路径已改成弹窗里的配置二维码(见 larkcli-device.js 的 config 阶段),
|
|
349
|
+
// 只有用户在弹窗里显式点「改用终端窗口」才会走到这里
|
|
340
350
|
msg: '将打开一个终端窗口执行 <code>lark-cli config init --new</code>:按窗口打印的链接在浏览器完成飞书应用创建,'
|
|
341
351
|
+ '完成后会自动进入授权登录。'
|
|
342
352
|
+ '<br><br>若本机没有可用终端(无桌面 / SSH 会话),需在服务器上手动执行该命令。',
|
|
@@ -281,7 +281,7 @@ export declare function cmdEscapeArg(s: string): string;
|
|
|
281
281
|
* cwd 设为专用 tmp 子目录:lark-cli 的写路径允许根是 cwd / /tmp / ~/files(实测 cwd 生效),
|
|
282
282
|
* 故 -o 用相对名即可落进允许根。
|
|
283
283
|
*/
|
|
284
|
-
export declare function defaultMakeQr(url: string, runner?: AuthRunner, log?: (m: string) => void, tmpDir?: string): Promise<string | undefined>;
|
|
284
|
+
export declare function defaultMakeQr(url: string, runner?: AuthRunner, log?: (m: string) => void, tmpDir?: string, fileName?: string): Promise<string | undefined>;
|
|
285
285
|
export interface LarkCliDeviceDeps {
|
|
286
286
|
runner: AuthRunner;
|
|
287
287
|
finishRunner: AuthRunner;
|
|
@@ -304,6 +304,107 @@ export declare function startLarkCliDeviceAuth(opts?: {
|
|
|
304
304
|
regenerate?: boolean;
|
|
305
305
|
deps?: Partial<LarkCliDeviceDeps>;
|
|
306
306
|
}): Promise<LarkCliDeviceSessionInfo>;
|
|
307
|
+
export type LarkCliConfigState = 'pending' | 'done' | 'failed' | 'expired' | 'none';
|
|
308
|
+
export interface LarkCliConfigSessionInfo {
|
|
309
|
+
ok: boolean;
|
|
310
|
+
state?: LarkCliConfigState;
|
|
311
|
+
verificationUrl?: string;
|
|
312
|
+
userCode?: string;
|
|
313
|
+
qrDataUrl?: string;
|
|
314
|
+
reused?: boolean;
|
|
315
|
+
error?: string;
|
|
316
|
+
/** 未安装——让前端把用户导到「安装」入口而不是干瞪眼 */
|
|
317
|
+
hint?: 'install';
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* 子进程最小结构(便于测试注入假实现)。
|
|
321
|
+
* 真实实现由 defaultConfigSpawn 把 node 的 ChildProcess 收窄成它。
|
|
322
|
+
*/
|
|
323
|
+
export interface LarkCliConfigChild {
|
|
324
|
+
stdout: {
|
|
325
|
+
on(ev: 'data', cb: (chunk: Buffer) => void): unknown;
|
|
326
|
+
} | null;
|
|
327
|
+
stderr: {
|
|
328
|
+
on(ev: 'data', cb: (chunk: Buffer) => void): unknown;
|
|
329
|
+
} | null;
|
|
330
|
+
on(ev: 'exit', cb: (code: number | null) => void): unknown;
|
|
331
|
+
on(ev: 'error', cb: (err: Error) => void): unknown;
|
|
332
|
+
}
|
|
333
|
+
export interface LarkCliConfigDeps {
|
|
334
|
+
spawn: (file: string, args: string[], opts: {
|
|
335
|
+
windowsHide: boolean;
|
|
336
|
+
env: NodeJS.ProcessEnv;
|
|
337
|
+
}) => LarkCliConfigChild;
|
|
338
|
+
detect(): Promise<LarkCliDetect>;
|
|
339
|
+
checkAuth(): Promise<LarkCliAuthStatus>;
|
|
340
|
+
makeQr(url: string): Promise<string | undefined>;
|
|
341
|
+
now(): number;
|
|
342
|
+
log(msg: string): void;
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* 纯函数(可测):从 `config init --new` 的输出里提取授权链接。
|
|
346
|
+
*
|
|
347
|
+
* **真实输出形态尚未用真机钉死**(本机已配置,直接跑不带 --name 的 config init 会覆盖它;
|
|
348
|
+
* 抓取步骤见 docs/e2e-checklist.md 的「页面内配置应用」一节),因此解析刻意宽容,
|
|
349
|
+
* 三层依次退让,保证形态猜错时也只是退化成更弱的匹配、而不是彻底解析不出来:
|
|
350
|
+
* ① JSON(兼容 {ok,data} 信封与 verification_uri / console_url 等字段别名)
|
|
351
|
+
* ② 裸 URL(从散文里抓首个 http(s) 链接)
|
|
352
|
+
* ③ 折行 URL(输出方按显示宽度插了换行时的尽力补救)
|
|
353
|
+
*
|
|
354
|
+
* **URL 一律视为 opaque string**:不编码、不解码、不重拼 query——lark-cli 内置 skill 明文要求。
|
|
355
|
+
*/
|
|
356
|
+
export declare function extractConfigUrl(text: string): string | undefined;
|
|
357
|
+
export interface LarkCliConfigOutcome {
|
|
358
|
+
phase: 'done' | 'failed' | 'expired';
|
|
359
|
+
error?: string;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* 纯函数(可测):配置子进程结束后判定结果。
|
|
363
|
+
*
|
|
364
|
+
* **正向判据优先于退出码**:以「应用是否真的配置上了」为准(`!needsLarkCliConfig(authAfter)`),
|
|
365
|
+
* 而不是退出码——退出码 0 却什么都没配上,比直接报错更糟(用户以为配好了,下一步授权必然失败)。
|
|
366
|
+
*
|
|
367
|
+
* 判据还必须是**明确的**探测结果:`unknown`(探测失败)不算已配置,否则一次探测抖动
|
|
368
|
+
* 就会把没配上的机器报成配好了——这与 decideDeviceFinishOutcome 的「反向兜底绝不能有」同源。
|
|
369
|
+
*/
|
|
370
|
+
export declare function decideConfigOutcome(a: {
|
|
371
|
+
exitCode: number | null;
|
|
372
|
+
output: string;
|
|
373
|
+
spawnError?: string;
|
|
374
|
+
authAfter?: LarkCliAuthStatus;
|
|
375
|
+
}): LarkCliConfigOutcome;
|
|
376
|
+
/** 内存态查询(零副作用、亚毫秒)——撑住前端 2s 轮询 */
|
|
377
|
+
export declare function getLarkCliConfigStatus(now?: number): LarkCliConfigSessionInfo;
|
|
378
|
+
/**
|
|
379
|
+
* 放弃当前配置会话(改用终端 / 新一轮发起前)。
|
|
380
|
+
* **不 kill 子进程**——与 abandonLarkCliDeviceSession 同源:Windows 上
|
|
381
|
+
* cmd → node → lark-cli.exe 的进程树 kill 会留孤儿,靠 gen 失配丢弃结果即可,
|
|
382
|
+
* 孤儿最多空转到 registration 过期后自行退出。
|
|
383
|
+
*/
|
|
384
|
+
export declare function abandonLarkCliConfigSession(): void;
|
|
385
|
+
/** 重置配置会话状态(测试用) */
|
|
386
|
+
export declare function resetLarkCliConfigState(): void;
|
|
387
|
+
/** 生产 spawn:stdin 接 /dev/null——官方要求「后台运行」,此路无人在终端应答交互输入 */
|
|
388
|
+
export declare const defaultConfigSpawn: (file: string, args: string[], opts: {
|
|
389
|
+
windowsHide: boolean;
|
|
390
|
+
env: NodeJS.ProcessEnv;
|
|
391
|
+
}) => LarkCliConfigChild;
|
|
392
|
+
/**
|
|
393
|
+
* 发起「配置应用」流程(页面二维码的主入口)。
|
|
394
|
+
*
|
|
395
|
+
* 四道闸与 startLarkCliDeviceAuth 同构:
|
|
396
|
+
* ① 幂等复用:pending 会话直接返回(挡住刷新页面 / 关弹窗再开)
|
|
397
|
+
* ② in-flight 合并:并发调用 await 同一个 promise(挡住双击)
|
|
398
|
+
* ③ regenerate:用户显式要求时才新建
|
|
399
|
+
* ④ gen 代号:新建时 +1,在飞的老子进程靠它丢弃过期结果
|
|
400
|
+
*
|
|
401
|
+
* 与设备流的一处**有意不同**:没有「剩余不足 X 秒就换新」——CLI 不吐有效期,
|
|
402
|
+
* 我们也就无从编造倒计时;会话的终结一律由子进程退出(或 CONFIG_URL_WAIT_MS 兜底)驱动。
|
|
403
|
+
*/
|
|
404
|
+
export declare function startLarkCliConfigFlow(opts?: {
|
|
405
|
+
regenerate?: boolean;
|
|
406
|
+
deps?: Partial<LarkCliConfigDeps>;
|
|
407
|
+
}): Promise<LarkCliConfigSessionInfo>;
|
|
307
408
|
export type LarkCliOp = 'install' | 'update' | 'auth' | 'config' | 'skill';
|
|
308
409
|
export interface LarkCliActionResult {
|
|
309
410
|
ok: boolean;
|