@jesonliu/lark-claudecode-bridge 1.0.5 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +6 -4
  2. package/assets/web/css/main.css +46 -0
  3. package/assets/web/index.html +1 -0
  4. package/assets/web/js/pages/apps.js +118 -6
  5. package/assets/web/js/pages/claude.js +4 -7
  6. package/assets/web/js/pages/larkcli-device.js +169 -63
  7. package/assets/web/js/pages/overview.js +86 -27
  8. package/assets/web/js/pages/permissions.js +45 -10
  9. package/assets/web/js/ui.js +167 -0
  10. package/config.example.yaml +20 -2
  11. package/dist/config-defaults.js +2 -1
  12. package/dist/config-defaults.js.map +1 -1
  13. package/dist/config.d.ts +4 -2
  14. package/dist/config.js +80 -5
  15. package/dist/config.js.map +1 -1
  16. package/dist/executor/claude-executor.d.ts +3 -0
  17. package/dist/executor/claude-executor.js +3 -0
  18. package/dist/executor/claude-executor.js.map +1 -1
  19. package/dist/executor/notify-server.d.ts +4 -18
  20. package/dist/executor/notify-server.js +6 -74
  21. package/dist/executor/notify-server.js.map +1 -1
  22. package/dist/executor/permission-gate.d.ts +10 -0
  23. package/dist/executor/permission-gate.js +24 -7
  24. package/dist/executor/permission-gate.js.map +1 -1
  25. package/dist/gateway/card-builder.d.ts +30 -19
  26. package/dist/gateway/card-builder.js +63 -46
  27. package/dist/gateway/card-builder.js.map +1 -1
  28. package/dist/gateway/diff-card.d.ts +1 -2
  29. package/dist/gateway/diff-card.js +2 -3
  30. package/dist/gateway/diff-card.js.map +1 -1
  31. package/dist/gateway/feishu-gateway.d.ts +2 -2
  32. package/dist/gateway/feishu-gateway.js +35 -5
  33. package/dist/gateway/feishu-gateway.js.map +1 -1
  34. package/dist/gateway/progress-card.d.ts +15 -5
  35. package/dist/gateway/progress-card.js +29 -14
  36. package/dist/gateway/progress-card.js.map +1 -1
  37. package/dist/index.js +198 -94
  38. package/dist/index.js.map +1 -1
  39. package/dist/lark-cli-manager.d.ts +102 -1
  40. package/dist/lark-cli-manager.js +353 -12
  41. package/dist/lark-cli-manager.js.map +1 -1
  42. package/dist/notify-sop.d.ts +1 -1
  43. package/dist/notify-sop.js +21 -26
  44. package/dist/notify-sop.js.map +1 -1
  45. package/dist/session/commands.d.ts +20 -2
  46. package/dist/session/commands.js +58 -17
  47. package/dist/session/commands.js.map +1 -1
  48. package/dist/types.d.ts +27 -7
  49. package/dist/web/autostart.d.ts +49 -0
  50. package/dist/web/autostart.js +250 -0
  51. package/dist/web/autostart.js.map +1 -0
  52. package/dist/web/config-api.js +14 -2
  53. package/dist/web/config-api.js.map +1 -1
  54. package/dist/web/server.js +54 -2
  55. package/dist/web/server.js.map +1 -1
  56. package/package.json +1 -1
@@ -1,12 +1,14 @@
1
- // ============ 飞书扫码授权(设备流):页面内二维码弹窗 ============
2
- // 与「拉终端」并列的第二条授权路径——把 lark-cli 的 device flow 搬到配置页里,用户直接
3
- // 扫码完成,不用切窗口。它不经过终端探测,因此无桌面 / SSH 环境同样可用(那条路上
4
- // 「去终端授权」原本是无出口的死路)。
1
+ // ============ 飞书配置应用 / 扫码授权:页面内二维码弹窗 ============
2
+ // 与「拉终端」并列的第二条路径——把 lark-cli 的 config init 与 device flow 都搬到配置页里,
3
+ // 用户直接扫码完成,不用切窗口。它不经过终端探测,因此无桌面 / SSH 环境同样可用
4
+ // (那条路上「去终端」原本是无出口的死路)。
5
5
  //
6
- // 三段式(后端编排,见 src/lark-cli-manager.ts 的设备流一节):
7
- // ① POST /device/start → 拿 verification_url + 二维码(base64 PNG)
8
- // ② 用户用飞书扫码
9
- // ③ 后端后台跑 `auth login --device-code` 收尾,前端只轮询零成本的内存态端点
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, onConfig,
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">用飞书 App 扫描下方二维码,按提示确认授权。授权成功后本页会自动更新。</div>
52
- <div id="lqdBody"><div class="hint">正在向飞书申请授权二维码…</div></div>
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 paintReady = (s) => {
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="飞书授权二维码" width="220" height="220"></div>`
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">二维码 <b id="lqdLeft">${fmtRemain(remainNow())}</b> 后失效`
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 tag = state === 'done' ? 'ok' : 'warn';
99
- const label = state === 'done' ? '已授权' : state === 'expired' ? '已过期' : '未完成';
100
- body.innerHTML = `<div><span class="tag ${tag}">${label}</span>`
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(deviceStateHint(state, error))}</div>`;
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
- * 应用未配置的引导态:device flow 必然被后端挡下(startLarkCliDeviceAuth 的前置检查),
110
- * 所以连请求都不发——直接把「为什么发不了码」和「下一步去哪」摆出来。
111
- * 「改用终端窗口」一并收起:那条路同样会撞上未配置守卫。
112
- */
113
- const paintNeedConfig = () => {
146
+ /** 后端把前置问题写成了可照做的文案(未安装 / 探测不出 / 已配置),直接透出 */
147
+ const paintFail = (msg) => {
114
148
  body.innerHTML = '<div><span class="tag warn">无法发起</span></div>'
115
- + '<div class="hint" style="margin-top:8px">飞书 CLI 还没配置过飞书应用,扫码授权无处可挂。</div>'
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 = true;
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', '/api/lark-cli/device/status');
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
- baseLeft = r.expiresInSec ?? remainNow();
146
- baseAt = Date.now();
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
- const doStart = async (regenerate) => {
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
- if (notConfigured) { paintNeedConfig(); return; } // 不发请求:后端必然以 not_configured 拒绝
235
+ setTitle('飞书授权(扫码完成)');
236
+ setLead('用飞书 App 扫描下方二维码,按提示确认授权。授权成功后本页会自动更新。');
165
237
  body.innerHTML = '<div class="hint">正在向飞书申请授权二维码…</div>';
166
- btnCopy.hidden = true;
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
- body.innerHTML = `<div><span class="tag warn">无法发起</span></div>`
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
- paintReady(session);
181
- poll = setInterval(doPoll, POLL_MS);
182
- tick = setInterval(() => {
183
- const el = body.querySelector('#lqdLeft');
184
- if (el) el.textContent = fmtRemain(remainNow());
185
- }, TICK_MS);
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
- mask.querySelector('[data-lqd="regen"]').onclick = () => void doStart(true);
300
+ btnRegen.onclick = () => void doStart(true);
198
301
  // 关弹窗**不动后端会话**(沿用终端弹窗「关弹窗不中断流程」的既有理念):
199
- // 重新打开时 start 不带 regenerate → 后端幂等复用同一个 device code,二维码原样回来
200
- btnTerminal.onclick = () => { close(); onTerminal?.(); };
201
- btnConfig.onclick = () => { close(); onConfig?.(); };
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
- import { openLarkCliDeviceDialog } from './larkcli-device.js';
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
- // 未配置应用时 device flow 必然被后端挡下(「请先点配置应用」):把这个前置条件
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
- onConfig: () => void larkCliActionFlow('config'),
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
- // 授权入口常驻,且已授权也给(token 会过期,随时可重走一遍 device flow 刷新)。
199
- // 授权走**页面内二维码**(主路径,无桌面环境同样可用);终端窗口降级为弹窗里的备选。
200
- // 应用未配置(not_configured)时**不在这里换按钮**——那会把唯一的授权入口顶掉,
201
- // 用户看到「配置应用」只会更困惑「那我怎么授权」。改由弹窗先讲清「得先配应用」
202
- // 并给出向导入口(见 openDeviceDialog 与 larkcli-device.js)。
203
- if (r.auth?.state === 'authorized') add('重新授权', 'auth-device');
204
- else add('扫码授权', 'auth-device');
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 会话),需在服务器上手动执行该命令。',
@@ -388,6 +398,45 @@ async function larkCliActionFlow(op = 'install') {
388
398
  }
389
399
  }
390
400
 
401
+ // ============ 开机自启开关(GET/POST /api/autostart;注册状态以 OS 查询为权威) ============
402
+ function paintAutostart(el, r) {
403
+ const tags = r.enabled
404
+ ? '<span class="tag ok">已开启</span>'
405
+ : '<span class="tag off">未开启</span>';
406
+ const stale = r.stale ? ' <span class="tag warn">启动命令已过期,关闭再开启可更新</span>' : '';
407
+ const hint = r.detail ? ` <span class="hint">${esc(r.detail)}</span>` : '';
408
+ el.innerHTML = `<span class="switch-row tight"><label class="switch"><input type="checkbox" id="asToggle" ${r.enabled ? 'checked' : ''}><span class="track"></span></label>${tags}${stale}${hint}</span>`;
409
+ $('#asToggle').onchange = async (e) => {
410
+ const on = e.target.checked;
411
+ try {
412
+ const res = await api('POST', '/api/autostart', { enabled: on });
413
+ toast(on ? '✅ 已注册开机自启(下次登录时自动启动)' : '已关闭开机自启');
414
+ paintAutostart(el, res);
415
+ } catch (err) {
416
+ toast(err.message, true);
417
+ e.target.checked = !on; // 失败回滚开关视觉态(注册失败不能显示为已开)
418
+ }
419
+ };
420
+ }
421
+
422
+ /** 概览页「开机自启」行:首次渲染拉状态;开关点击即生效(低风险,与权限页即时保存一致) */
423
+ async function renderAutostart() {
424
+ const el = $('#asStat');
425
+ if (!el) return;
426
+ let r;
427
+ try {
428
+ r = await api('GET', '/api/autostart');
429
+ } catch (e) {
430
+ el.innerHTML = `<span class="hint">检测失败:${esc(e.message)}</span>`;
431
+ return;
432
+ }
433
+ if (!r.supported) {
434
+ el.innerHTML = `<span class="tag off">不支持</span><span class="hint">${esc(r.detail || '当前运行方式不支持开机自启,请手动运行 lcb start')}</span>`;
435
+ return;
436
+ }
437
+ paintAutostart(el, r);
438
+ }
439
+
391
440
  export const page = {
392
441
  id: 'overview',
393
442
  title: '概览',
@@ -424,20 +473,29 @@ export const page = {
424
473
  <tr><td>配置文件</td><td><code>${esc(st.configPath)}</code></td></tr>
425
474
  <tr><td>配置页</td><td><code>${esc(location.host)}</code></td></tr>
426
475
  <tr><td>Claude 认证</td><td>${claudeTag}(模式:${esc(st.claude?.mode || 'inherit')})</td></tr>
476
+ <tr><td>开机自启</td><td id="asStat"><span class="hint">检测中…</span></td></tr>
427
477
  </table>
428
478
  ${ctl}
429
479
  <div class="desc" style="margin-top:10px">${st.claude?.hasAuth === false ? '⚠️ 未检测到认证:到「Claude 认证」页填写,或在配置页所在机器执行 claude login。' : ''}</div>
430
480
  </div>
431
481
  <div class="card">
432
482
  <h3>版本与更新</h3>
433
- <table>
434
- <tr><td>当前版本</td><td><span class="chip">v${esc(st.version)}</span></td></tr>
435
- <tr><td>最新版本</td><td id="updLatest"><span class="hint">未检查</span></td></tr>
436
- <tr><td>飞书 CLI(lark-cli)</td><td id="larkCliStat"><span class="hint">检测中…</span></td></tr>
437
- </table>
438
- <div style="display:flex; gap:10px; margin-top:12px; align-items:center">
439
- <button class="btn" id="btnUpdCheck">检查更新</button>
440
- <span id="updArea"></span>
483
+ <div class="card-sub">
484
+ <h4 class="card-sub-title">▍LCB 桥接器</h4>
485
+ <table>
486
+ <tr><td>当前版本</td><td><span class="chip">v${esc(st.version)}</span></td></tr>
487
+ <tr><td>最新版本</td><td id="updLatest"><span class="hint">未检查</span></td></tr>
488
+ </table>
489
+ <div style="display:flex; gap:10px; margin-top:12px; align-items:center">
490
+ <button class="btn" id="btnUpdCheck">检查更新</button>
491
+ <span id="updArea"></span>
492
+ </div>
493
+ </div>
494
+ <div class="card-sub">
495
+ <h4 class="card-sub-title">▍飞书 CLI(lark-cli)</h4>
496
+ <table>
497
+ <tr><td>状态</td><td id="larkCliStat"><span class="hint">检测中…</span></td></tr>
498
+ </table>
441
499
  </div>
442
500
  </div>
443
501
  <div class="card">
@@ -467,5 +525,6 @@ export const page = {
467
525
  else doCheckUpdate();
468
526
  if (LARKCLI.data && Date.now() - LARKCLI.checkedAt < 30000) renderLarkCliResult();
469
527
  else doCheckLarkCli();
528
+ void renderAutostart();
470
529
  },
471
530
  };
@@ -23,19 +23,31 @@ function renderPermissions(el) {
23
23
  function renderAllowTab(panel, p) {
24
24
  const defaults = S.status?.permissionDefaults?.allowTools || [];
25
25
  const isDefault = defaults.length > 0 && JSON.stringify(p.allow_tools) === JSON.stringify(defaults);
26
+ // 缺省即开(与后端 DEFAULT_ALLOW_ALL_TOOLS 一致):只有显式 false 才算用户主动关闭
27
+ const allOn = p.allow_all_tools !== false;
26
28
  panel.innerHTML = `
27
29
  <div class="card">
28
30
  <h3>免确认工具</h3>
29
- <div class="desc">名单内工具直接放行不弹确认卡(配置即整体替换内置默认)。Bash 仍受危险命令黑名单约束。</div>
30
- <div class="list-toolbar">
31
- <input type="text" id="newTool" placeholder="工具名,如 Write" style="max-width:320px">
32
- <button class="btn" id="addTool">添加</button>
33
- <button class="btn" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
31
+ <div class="switch-row">
32
+ <label class="switch">
33
+ <input type="checkbox" id="allowAll" ${allOn ? 'checked' : ''}>
34
+ <span class="track"></span>
35
+ </label>
36
+ <span class="switch-label">全部工具免确认(除危险命令黑名单外)</span>
34
37
  </div>
35
- ${p.allow_tools.length === 0 ? '<div style="background:var(--warn-bg);color:var(--warn-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</div>' : ''}
36
- ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
37
- <table><tbody id="toolBody"></tbody></table>
38
- <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>
38
+ ${allOn ? '<div style="background:var(--warn-bg);color:var(--warn-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 已开启全部放行:除命中危险命令黑名单的 Bash 外,所有工具(含 Write/Edit)都不再弹确认卡,下方名单暂不生效</div>' : ''}
39
+ <div class="${allOn ? 'block-disabled' : ''}">
40
+ <div class="desc">名单内工具直接放行不弹确认卡(配置即整体替换内置默认)。Bash 仍受危险命令黑名单约束。</div>
41
+ <div class="list-toolbar">
42
+ <input type="text" id="newTool" placeholder="工具名,如 Write" style="max-width:320px">
43
+ <button class="btn" id="addTool">添加</button>
44
+ <button class="btn" id="resetTools" ${defaults.length ? '' : 'disabled title="status 不可用,无法取默认值"'}>恢复默认</button>
45
+ </div>
46
+ ${p.allow_tools.length === 0 ? '<div style="background:var(--warn-bg);color:var(--warn-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 名单为空:所有工具(含 Write/Edit)都会弹确认卡</div>' : ''}
47
+ ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
48
+ <table><tbody id="toolBody"></tbody></table>
49
+ </div>
50
+ <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:${allOn ? '全部免确认已开启,除危险命令外一律直通' : '白名单命中直通,未命中弹确认卡'}。</div>
39
51
  </div>`;
40
52
  $('#toolBody').innerHTML = p.allow_tools.map((t, i) =>
41
53
  `<tr><td><code>${esc(t)}</code></td><td style="width:80px"><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')
@@ -71,6 +83,29 @@ function renderAllowTab(panel, p) {
71
83
  p.allow_tools = [...defaults];
72
84
  if (!await saveDoc()) p.allow_tools = old;
73
85
  };
86
+ // 开关即时落盘:关闭(收紧密)直接生效;开启(放宽到全放行)先过确认弹层,避免误点放行全部写操作
87
+ $('#allowAll').onchange = async (e) => {
88
+ const next = e.target.checked;
89
+ if (next && !(await confirmDialog({
90
+ title: '开启全部工具免确认',
91
+ message: '开启后除命中危险命令黑名单的 Bash 外,<b>所有工具(含 Write/Edit)</b>都不再弹确认卡。确定开启?',
92
+ danger: true,
93
+ confirmText: '开启',
94
+ }))) {
95
+ e.target.checked = false;
96
+ return;
97
+ }
98
+ const old = p.allow_all_tools;
99
+ p.allow_all_tools = next;
100
+ // 成功路径不手动重渲染:saveDoc 内部的 refresh() 已整页重渲染并重新绑定事件(用最新的
101
+ // S.doc.permissions)。此处若再调 renderAllowTab(panel, p),panel 早已脱离文档,而
102
+ // $() 走 document 查询——那会把新 DOM 上的 handler 覆盖成闭包里旧 p(孤儿对象)的版本,
103
+ // 之后开关改动就落不到 S.doc 上(表现为「点了保存成功但配置没变」)。与增删条目同款模式。
104
+ if (!await saveDoc()) {
105
+ p.allow_all_tools = old;
106
+ e.target.checked = !next; // 失败不重渲染,DOM 仍是本次交互的元素,直接回滚勾选态
107
+ }
108
+ };
74
109
  }
75
110
 
76
111
  function renderDangerTab(panel, p) {
@@ -88,7 +123,7 @@ function renderDangerTab(panel, p) {
88
123
  ${p.dangerous_commands.length === 0 ? '<div style="background:var(--err-bg);color:var(--err-tx);border-radius:7px;padding:7px 12px;margin:8px 0">⚠️ 黑名单为空:所有 Bash 命令(含 rm -rf、sudo)将免确认直通,请确认有意为之</div>' : ''}
89
124
  ${isDefault ? '<div class="hint" style="margin:8px 0"><span class="tag off">与内置默认一致</span></div>' : ''}
90
125
  <table><tbody id="dcBody"></tbody></table>
91
- <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。plan 模式下写操作仍会经此判定:白名单命中直通,未命中弹确认卡。</div>
126
+ <div class="footer-note">权限改动即时保存并热生效(已有会话的下一个工具调用即用新名单)。「全部工具免确认」开启时本黑名单是唯一的安全网——删除正则等于放行对应命令。</div>
92
127
  </div>`;
93
128
  $('#dcBody').innerHTML = p.dangerous_commands.map((s, i) =>
94
129
  `<tr><td style="width:90%"><code>${esc(s)}</code></td><td><button class="btn sm danger" data-i="${i}">删除</button></td></tr>`).join('')