@jesonliu/lark-claudecode-bridge 1.0.4 → 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.
@@ -18,7 +18,7 @@
18
18
  // 自动进入「Agent 凭证绑定」分支(要求 config bind 而拒绝 config init)。bridge 是
19
19
  // 飞书+ClaudeCode 专用进程,与这些 Agent 无关——lcb start 入口统一剔除(见 bin/lcb.ts),
20
20
  // 本模块探测也走剔除后的 env,保证 lark-cli 始终走标准 init/login 路径。
21
- import { execFile } from 'node:child_process';
21
+ import { execFile, spawn } from 'node:child_process';
22
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
23
23
  import { homedir, tmpdir } from 'node:os';
24
24
  import { join } from 'node:path';
@@ -211,12 +211,7 @@ export async function checkLarkCliLatest(timeoutMs = 15_000) {
211
211
  export function isFeishuSkillDirName(name) {
212
212
  return /feishu|lark/i.test(name);
213
213
  }
214
- /**
215
- * 检测飞书官方 SKILL 是否已装(默认 ~/.claude/skills,与安装动作同源)。
216
- * 判据:目录名命中 feishu/lark **且** 内含 SKILL.md(防同名散目录误报)。
217
- * managed 会话的可见性由 bridgeUserSkills() 启动桥接保证,这里不查托管目录。
218
- */
219
- export async function detectLarkCliSkill(skillsDir = join(homedir(), '.claude', 'skills'), fs = {
214
+ const defaultSkillFs = {
220
215
  exists: existsSync,
221
216
  readdir: (d) => { try {
222
217
  return readdirSync(d);
@@ -224,16 +219,31 @@ export async function detectLarkCliSkill(skillsDir = join(homedir(), '.claude',
224
219
  catch {
225
220
  return [];
226
221
  } },
227
- }) {
222
+ };
223
+ /**
224
+ * 列出已装的飞书官方 SKILL 目录名(默认 ~/.claude/skills,与安装动作同源)。
225
+ * 判据:目录名命中 feishu/lark **且** 内含 SKILL.md(防同名散目录误报)。
226
+ * managed 会话的可见性由 bridgeUserSkills() 启动桥接保证,这里不查托管目录。
227
+ *
228
+ * 以扫盘而非解析 `npx skills add` 的输出来确定「装了什么」:那份输出是带 ANSI 的
229
+ * 进度表,既难读也不可靠;目录才是既成事实。
230
+ */
231
+ export async function listLarkCliSkills(skillsDir = join(homedir(), '.claude', 'skills'), fs = defaultSkillFs) {
228
232
  if (!fs.exists(skillsDir))
229
- return { installed: false };
233
+ return [];
234
+ const names = [];
230
235
  for (const name of fs.readdir(skillsDir)) {
231
236
  if (!isFeishuSkillDirName(name))
232
237
  continue;
233
238
  if (fs.exists(join(skillsDir, name, 'SKILL.md')))
234
- return { installed: true, name };
239
+ names.push(name);
235
240
  }
236
- return { installed: false };
241
+ return names;
242
+ }
243
+ /** 检测飞书官方 SKILL 是否已装:取扫盘结果的首个(保持既有返回结构不变) */
244
+ export async function detectLarkCliSkill(skillsDir = join(homedir(), '.claude', 'skills'), fs = defaultSkillFs) {
245
+ const [name] = await listLarkCliSkills(skillsDir, fs);
246
+ return name ? { installed: true, name } : { installed: false };
237
247
  }
238
248
  /**
239
249
  * 安装飞书官方 SKILL(`npx skills add`,落盘 ~/.claude/skills):
@@ -263,47 +273,34 @@ export function needsLarkCliConfig(auth) {
263
273
  return auth.state === 'unauthorized' && /not_configured/i.test(auth.detail ?? '');
264
274
  }
265
275
  /**
266
- * 核心编排(依赖注入可测):已装 → 直接返回;未装 → install → 复检;
267
- * install 抛错 → warn 后返回未装态,**绝不抛出**(桥接器启动不能被外部工具安装失败拖垮)
276
+ * 启动时的飞书 CLI 检查(依赖注入可测)——**只探测,绝不安装**。
277
+ *
278
+ * 安装统一下沉到 Web 配置页(用户决策):启动时静默装会让用户对安装过程无感,
279
+ * 也就跳过了官方流程的后三步(SKILL / config init / auth login)——等打开配置页时
280
+ * CLI 已就绪却从未配置过飞书应用,直接卡在「未授权」而无从下手;用户此时若自己点
281
+ * 「安装」,还会和后台那次抢 npm 文件锁。这里只把现状与去处讲清楚。
282
+ *
283
+ * **绝不抛出**:桥接器启动不能被外部工具的探测异常拖垮。
268
284
  */
269
- export async function ensureLarkCliFlow(deps) {
270
- const log = deps.log ?? (() => { });
271
- const first = await deps.detect();
272
- if (first.installed) {
273
- log(`✅ lark-cli 已安装${first.version ? ` v${first.version}` : '(版本未知)'}`);
274
- return first;
275
- }
276
- log('未检测到飞书官方 CLI,自动安装中(npm install -g @larksuite/cli,遵循 .npmrc 镜像)…');
277
- try {
278
- await deps.install(log);
279
- }
280
- catch (e) {
281
- log(`⚠️ 自动安装失败(不影响桥接器运行,可手动 npm i -g @larksuite/cli):${e instanceof Error ? e.message : String(e)}`);
282
- return { installed: false };
285
+ export async function checkLarkCliAtStartup(deps = {}) {
286
+ const detect = deps.detect ?? (() => detectLarkCli());
287
+ const detectSkill = deps.detectSkill ?? (() => detectLarkCliSkill());
288
+ const log = deps.log ?? ((msg) => console.log('[lark-cli]', msg));
289
+ const d = await detect();
290
+ if (!d.installed) {
291
+ log('未检测到飞书官方 CLI(@larksuite/cli)。安装统一由配置页发起,启动时不自动装——'
292
+ + '请打开 Web 配置页(lcb ui),在「飞书 CLI」一栏点「安装」');
293
+ return d;
283
294
  }
284
- const again = await deps.detect();
285
- log(again.installed
286
- ? `✅ lark-cli 安装完成${again.version ? ` v${again.version}` : ''}`
287
- : '⚠️ 安装命令已执行但仍未检测到 lark-cli(可手动 npm i -g @larksuite/cli 后重试)');
288
- return again;
289
- }
290
- /** 生产入口:console 反馈([lark-cli] 前缀),后台异步调用(lcb start 里 void + catch) */
291
- export async function ensureLarkCli() {
292
- const detect = await ensureLarkCliFlow({
293
- detect: () => detectLarkCli(),
294
- install: () => installLarkCli(),
295
- log: (msg) => console.log('[lark-cli]', msg),
296
- });
295
+ log(`✅ lark-cli 已安装${d.version ? ` v${d.version}` : '(版本未知)'}`);
297
296
  // 官方安装第 ② 步(SKILL):启动链只告警不自动装——skills add 要走网络下载,
298
297
  // 不能让桥接器启动背这个任务;Web 概览页提供「装 SKILL」按钮
299
- if (detect.installed) {
300
- const skill = await detectLarkCliSkill().catch(() => ({ installed: false }));
301
- if (!skill.installed) {
302
- console.log('[lark-cli] ⚠️ 官方 SKILL 未安装(教 AI 怎么用 lark-cli 的说明书):'
303
- + '请在 Web 配置页概览区点「装 SKILL」,或手动执行 npx -y skills add https://open.feishu.cn --skill \'*\' -g -a claude-code --copy -y');
304
- }
298
+ const skill = await detectSkill().catch(() => ({ installed: false }));
299
+ if (!skill.installed) {
300
+ log('⚠️ 官方 SKILL 未安装(教 AI 怎么用 lark-cli 的说明书):'
301
+ + '请在 Web 配置页概览区点「装 SKILL」,或手动执行 npx -y skills add https://open.feishu.cn --skill \'*\' -g -a claude-code --copy -y');
305
302
  }
306
- return detect;
303
+ return d;
307
304
  }
308
305
  /** 未授权类信号(ok:true 也可能带这些 reason —— 见 parseLarkCliAuth 的第二个实测陷阱) */
309
306
  const NEGATIVE_REASON = /not_configured|not_authenticated|unauthenticated|no_credential|expired/i;
@@ -660,10 +657,14 @@ function deviceInfo(now = Date.now(), withQr = false) {
660
657
  export function getLarkCliDeviceStatus(now = Date.now()) {
661
658
  return deviceInfo(now);
662
659
  }
663
- /** 清掉临时二维码文件(尽力而为,失败不影响流程) */
664
- function removeQrFile(log) {
660
+ /**
661
+ * 清掉临时二维码文件(尽力而为,失败不影响流程)。
662
+ * **必须带文件名**:设备流与配置流各用各的文件,否则一边 abandon 会把另一边
663
+ * 正在生成/读回的二维码删掉,或两条流互相覆盖同一文件导致画面张冠李戴。
664
+ */
665
+ function removeQrFile(log, fileName = 'qr.png') {
665
666
  try {
666
- rmSync(join(larkCliQrDir(), 'qr.png'), { force: true });
667
+ rmSync(join(larkCliQrDir(), fileName), { force: true });
667
668
  }
668
669
  catch (e) {
669
670
  log(`清理二维码临时文件失败:${e instanceof Error ? e.message : String(e)}`);
@@ -705,9 +706,9 @@ export function cmdEscapeArg(s) {
705
706
  * cwd 设为专用 tmp 子目录:lark-cli 的写路径允许根是 cwd / /tmp / ~/files(实测 cwd 生效),
706
707
  * 故 -o 用相对名即可落进允许根。
707
708
  */
708
- export async function defaultMakeQr(url, runner = defaultAuthRunner, log = () => { }, tmpDir) {
709
+ export async function defaultMakeQr(url, runner = defaultAuthRunner, log = () => { }, tmpDir, fileName = 'qr.png') {
709
710
  const dir = larkCliQrDir(tmpDir);
710
- const args = ['auth', 'qrcode', url, '-o', 'qr.png', '--size', '512'];
711
+ const args = ['auth', 'qrcode', url, '-o', fileName, '--size', '512'];
711
712
  const opts = { timeout: 10_000, windowsHide: true, encoding: 'utf8', env: stripAgentContextEnv(process.env), cwd: dir };
712
713
  try {
713
714
  mkdirSync(dir, { recursive: true });
@@ -723,9 +724,9 @@ export async function defaultMakeQr(url, runner = defaultAuthRunner, log = () =>
723
724
  if (!done) {
724
725
  // 兜底路径要转义——这里没有 node 可锚定,只能经 cmd
725
726
  const d = larkCliDirect();
726
- await runner(d.file, [...d.prefixArgs, 'auth', 'qrcode', cmdEscapeArg(url), '-o', 'qr.png', '--size', '512'], opts);
727
+ await runner(d.file, [...d.prefixArgs, 'auth', 'qrcode', cmdEscapeArg(url), '-o', fileName, '--size', '512'], opts);
727
728
  }
728
- return `data:image/png;base64,${readFileSync(join(dir, 'qr.png')).toString('base64')}`;
729
+ return `data:image/png;base64,${readFileSync(join(dir, fileName)).toString('base64')}`;
729
730
  }
730
731
  catch (e) {
731
732
  log(`二维码生成失败(降级为只显示链接):${e instanceof Error ? e.message : String(e)}`);
@@ -860,6 +861,341 @@ export async function startLarkCliDeviceAuth(opts = {}) {
860
861
  deviceStartInflight = null;
861
862
  }
862
863
  }
864
+ let configSession = null;
865
+ let configGen = 0;
866
+ let configStartInflight = null;
867
+ /**
868
+ * 迟迟解析不到链接的上限。它只负责**给前端一个出口**(UI 不留无出口死角):
869
+ * 走到这里说明命令既没吐链接、也没退出(网络卡住 / 输出形态和预期不一样)。
870
+ */
871
+ const CONFIG_URL_WAIT_MS = 120_000;
872
+ /** start 请求内等待链接的时间:正常就是一次网络往返,超了就改为让前端轮询 status */
873
+ const CONFIG_START_WAIT_MS = 30_000;
874
+ /** 子进程输出缓冲上限(防无限增长;链接只有几百字节,余量充足) */
875
+ const CONFIG_OUTPUT_CAP = 64 * 1024;
876
+ /** 输出安静这么久就认为最后那行写完了(见 watchLarkCliConfig 的行缓冲注释) */
877
+ const CONFIG_QUIET_MS = 500;
878
+ /**
879
+ * 配置流专用二维码文件名。**必须与设备流的 qr.png 分开**:两边各写各的文件,
880
+ * 否则并发时后写的一次会覆盖前一次,把二维码画成另一条流的链接
881
+ * (abandon 时的清理也会误删对方正在读回的文件)。
882
+ */
883
+ const CONFIG_QR_FILE = 'qr-config.png';
884
+ /** 可在外部 resolve 的 Promise(避免把 resolve 引用深埋进 spawn 回调) */
885
+ function makeDeferred() {
886
+ let resolve;
887
+ const promise = new Promise((r) => { resolve = r; });
888
+ return { promise, resolve };
889
+ }
890
+ /** URL 字符集刻意收窄到 ASCII:中文、全角括号、引号天然是终止符,不会落进链接 */
891
+ const CONFIG_URL_RE = /https?:\/\/[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%]+/;
892
+ /**
893
+ * 纯函数(可测):取首个 URL,并剔除从散文 / 成对括号里粘来的尾部标点。
894
+ * 收尾括号只在**不成对**时剔除——`…?a=(x)` 这种自带配对的链接不能被误伤。
895
+ */
896
+ function firstUrl(text) {
897
+ const m = CONFIG_URL_RE.exec(text);
898
+ if (!m)
899
+ return undefined;
900
+ let url = m[0].replace(/[.,;:!?'")】」』]+$/, '');
901
+ if (!url.includes('('))
902
+ url = url.replace(/\)+$/, '');
903
+ if (!url.includes('['))
904
+ url = url.replace(/\]+$/, '');
905
+ return url;
906
+ }
907
+ /**
908
+ * 纯函数(可测):从 `config init --new` 的输出里提取授权链接。
909
+ *
910
+ * **真实输出形态尚未用真机钉死**(本机已配置,直接跑不带 --name 的 config init 会覆盖它;
911
+ * 抓取步骤见 docs/e2e-checklist.md 的「页面内配置应用」一节),因此解析刻意宽容,
912
+ * 三层依次退让,保证形态猜错时也只是退化成更弱的匹配、而不是彻底解析不出来:
913
+ * ① JSON(兼容 {ok,data} 信封与 verification_uri / console_url 等字段别名)
914
+ * ② 裸 URL(从散文里抓首个 http(s) 链接)
915
+ * ③ 折行 URL(输出方按显示宽度插了换行时的尽力补救)
916
+ *
917
+ * **URL 一律视为 opaque string**:不编码、不解码、不重拼 query——lark-cli 内置 skill 明文要求。
918
+ */
919
+ export function extractConfigUrl(text) {
920
+ const clean = stripAnsiAndBom(text);
921
+ const obj = firstJsonObject(clean);
922
+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {
923
+ const url = str(pickField(obj, [
924
+ 'verification_url', 'verification_uri', 'verification_uri_complete',
925
+ 'verificationUrl', 'verificationUri', 'console_url', 'consoleUrl', 'url',
926
+ ]));
927
+ if (url && /^https?:\/\//i.test(url))
928
+ return url;
929
+ }
930
+ const direct = firstUrl(clean);
931
+ // 折行补救:链接若被输出方按显示宽度折断,直接匹配只能拿到**前半截**——那是一个错的
932
+ // 链接,比拿不到更糟。所以只在「去换行后的结果以直接匹配为前缀且更长」时才采用它:
933
+ // 这个启发式只会把链接补全,绝不会把它换成另一段无关文本。
934
+ const joinedText = clean.replace(/\r?\n(?=[A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=%])/g, '');
935
+ // 拼接可能把上下文里的另一条 URL 接上来,那样得到的仍是错的——只认「整段只有一条 URL」
936
+ if ((joinedText.match(/https?:\/\//gi) ?? []).length !== 1)
937
+ return direct;
938
+ const joined = firstUrl(joinedText);
939
+ if (joined && (!direct || (joined.length > direct.length && joined.startsWith(direct))))
940
+ return joined;
941
+ return direct;
942
+ }
943
+ /**
944
+ * 纯函数(可测):配置子进程结束后判定结果。
945
+ *
946
+ * **正向判据优先于退出码**:以「应用是否真的配置上了」为准(`!needsLarkCliConfig(authAfter)`),
947
+ * 而不是退出码——退出码 0 却什么都没配上,比直接报错更糟(用户以为配好了,下一步授权必然失败)。
948
+ *
949
+ * 判据还必须是**明确的**探测结果:`unknown`(探测失败)不算已配置,否则一次探测抖动
950
+ * 就会把没配上的机器报成配好了——这与 decideDeviceFinishOutcome 的「反向兜底绝不能有」同源。
951
+ */
952
+ export function decideConfigOutcome(a) {
953
+ const auth = a.authAfter;
954
+ if (auth && auth.state !== 'unknown' && !needsLarkCliConfig(auth))
955
+ return { phase: 'done' };
956
+ if (a.spawnError)
957
+ return { phase: 'failed', error: `配置命令未能启动:${a.spawnError}` };
958
+ const text = a.output.replace(/\s+/g, ' ').trim();
959
+ if (/begin timed out|timed out|expired/i.test(text)) {
960
+ return { phase: 'expired', error: '配置链接已失效,请重新生成二维码' };
961
+ }
962
+ if (/cancel/i.test(text))
963
+ return { phase: 'failed', error: '配置已取消' };
964
+ // 探测失败(authAfter 缺失)时不能报成功,但也不该含糊其辞——把「没法确认」讲清楚
965
+ if (!auth) {
966
+ return {
967
+ phase: 'failed',
968
+ error: `配置命令已结束,但无法确认配置结果(状态探测失败)${text ? `:${text.slice(0, 120)}` : ''}`,
969
+ };
970
+ }
971
+ if (a.exitCode === 0) {
972
+ return { phase: 'failed', error: '配置命令已结束,但仍检测不到飞书应用(可能没在浏览器里完成创建)' };
973
+ }
974
+ return {
975
+ phase: 'failed',
976
+ error: text ? `配置未完成:${text.slice(0, 120)}` : `配置未完成(退出码 ${a.exitCode ?? '未知'})`,
977
+ };
978
+ }
979
+ /**
980
+ * 唯一的出网构造点:**显式挑字段**(不是 {...session} 展开),与 deviceInfo 同源约定——
981
+ * 配置流眼下没有 secret 类字段,但这条约定要一并继承,免得日后加字段时悄悄破防。
982
+ * 顺带做懒超时:迟迟拿不到链接的 pending 就地转 failed,前端不会永远转圈。
983
+ */
984
+ function configInfo(now = Date.now()) {
985
+ const s = configSession;
986
+ if (!s)
987
+ return { ok: true, state: 'none' };
988
+ if (s.state === 'pending' && !s.verificationUrl && now - s.startedAt > CONFIG_URL_WAIT_MS) {
989
+ s.state = 'failed';
990
+ s.error = s.error ?? `等待配置链接超时(${Math.round(CONFIG_URL_WAIT_MS / 1000)} 秒内未从 lark-cli 输出中解析到链接)`;
991
+ }
992
+ const base = { ok: true, state: s.state, userCode: s.userCode, error: s.error };
993
+ if (s.state === 'pending') {
994
+ base.verificationUrl = s.verificationUrl;
995
+ // 与设备流不同,这里 **status 也带二维码**:设备流的链接是 start 同步拿到的;
996
+ // 配置流的链接要等子进程输出,start 有可能等不到就返回(见 CONFIG_START_WAIT_MS),
997
+ // status 不带图的话那种情况下前端将永远拿不到二维码。
998
+ // 代价是 pending 期间每 2s 多传约 2KB,且只走 localhost、弹窗生命周期很短——划算。
999
+ base.qrDataUrl = s.qrDataUrl;
1000
+ }
1001
+ return base;
1002
+ }
1003
+ /** 内存态查询(零副作用、亚毫秒)——撑住前端 2s 轮询 */
1004
+ export function getLarkCliConfigStatus(now = Date.now()) {
1005
+ return configInfo(now);
1006
+ }
1007
+ /**
1008
+ * 放弃当前配置会话(改用终端 / 新一轮发起前)。
1009
+ * **不 kill 子进程**——与 abandonLarkCliDeviceSession 同源:Windows 上
1010
+ * cmd → node → lark-cli.exe 的进程树 kill 会留孤儿,靠 gen 失配丢弃结果即可,
1011
+ * 孤儿最多空转到 registration 过期后自行退出。
1012
+ */
1013
+ export function abandonLarkCliConfigSession() {
1014
+ if (!configSession)
1015
+ return;
1016
+ configGen++;
1017
+ configSession = null;
1018
+ removeQrFile(() => { }, CONFIG_QR_FILE);
1019
+ }
1020
+ /** 重置配置会话状态(测试用) */
1021
+ export function resetLarkCliConfigState() {
1022
+ configSession = null;
1023
+ configGen = 0;
1024
+ configStartInflight = null;
1025
+ }
1026
+ /** 生产 spawn:stdin 接 /dev/null——官方要求「后台运行」,此路无人在终端应答交互输入 */
1027
+ export const defaultConfigSpawn = (file, args, opts) => spawn(file, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
1028
+ /**
1029
+ * 解析该用哪条路启动 config init。
1030
+ * 优先 node + 包内入口(锚定本机真实安装、不依赖 PATH),与 defaultMakeQr 同序。
1031
+ */
1032
+ async function resolveConfigCommand() {
1033
+ try {
1034
+ const entry = larkCliEntryPath(await npmGlobalPrefix());
1035
+ if (existsSync(entry))
1036
+ return { file: process.execPath, prefixArgs: [entry] };
1037
+ }
1038
+ catch { /* 取不到 npm 前缀就落 PATH 直调 */ }
1039
+ return larkCliDirect();
1040
+ }
1041
+ /** 子进程结束后写回会话(gen 失配即丢弃,绝不污染新会话) */
1042
+ async function finishConfig(s, deps, output, code, spawnError) {
1043
+ if (configSession?.gen !== s.gen || s.state !== 'pending')
1044
+ return;
1045
+ let authAfter;
1046
+ try {
1047
+ authAfter = await deps.checkAuth();
1048
+ }
1049
+ catch { /* 探测失败留 undefined,判据会拒绝它 */ }
1050
+ if (configSession?.gen !== s.gen || s.state !== 'pending')
1051
+ return;
1052
+ const out = decideConfigOutcome({ exitCode: code, output, spawnError, authAfter });
1053
+ s.state = out.phase;
1054
+ s.error = out.error;
1055
+ deps.log(`配置应用会话结束:${out.phase}${out.error ? `(${out.error})` : ''}`);
1056
+ }
1057
+ /**
1058
+ * 后台守候配置子进程:流式抓链接 + 退出收尾。**同步返回、不 await**——
1059
+ * 用户可能几分钟后才在浏览器里完成。
1060
+ */
1061
+ function watchLarkCliConfig(s, deps, cmd, urlReady) {
1062
+ let buf = '';
1063
+ let settled = false;
1064
+ let quietTimer;
1065
+ const settle = (code, spawnError) => {
1066
+ if (settled)
1067
+ return; // 'error' 与 'exit' 可能都触发;且只有第一次算数
1068
+ settled = true;
1069
+ if (quietTimer) {
1070
+ clearTimeout(quietTimer);
1071
+ quietTimer = undefined;
1072
+ }
1073
+ urlReady(s.verificationUrl); // 没抓到链接也要放行 start,别让它空等到超时
1074
+ void finishConfig(s, deps, buf, code, spawnError);
1075
+ };
1076
+ /** 认下这段文本里的链接;已认过或没找到就返回 false(不改状态) */
1077
+ const tryLatch = (text) => {
1078
+ if (s.verificationUrl)
1079
+ return false;
1080
+ const url = extractConfigUrl(text);
1081
+ if (!url)
1082
+ return false;
1083
+ s.verificationUrl = url;
1084
+ s.userCode = userCodeFromUrl(url); // 只读提取;URL 本身仍按 opaque string 原样使用
1085
+ deps.log(`已从 lark-cli 输出中解析到配置链接(${buf.length} 字节输出内)`);
1086
+ urlReady(url);
1087
+ return true;
1088
+ };
1089
+ let child;
1090
+ try {
1091
+ child = deps.spawn(cmd.file, [...cmd.prefixArgs, 'config', 'init', '--new'], {
1092
+ windowsHide: true,
1093
+ env: stripAgentContextEnv(process.env),
1094
+ });
1095
+ }
1096
+ catch (e) {
1097
+ settle(null, e instanceof Error ? e.message : String(e));
1098
+ return;
1099
+ }
1100
+ const onChunk = (d) => {
1101
+ buf += String(d);
1102
+ if (quietTimer) {
1103
+ clearTimeout(quietTimer);
1104
+ quietTimer = undefined;
1105
+ } // 又来数据了,重新计时
1106
+ if (!s.verificationUrl) {
1107
+ // ① 先只认「已完整到达的行」:chunk 边界可能正好把链接劈成两半,拿半截 URL 去生成
1108
+ // 二维码会得到一张**指向错误地址**的图——比拿不到更糟。所以等换行到了再认。
1109
+ const complete = buf.slice(0, buf.lastIndexOf('\n') + 1);
1110
+ if (!complete || !tryLatch(complete)) {
1111
+ // ② 最后那行还没换行,先不认;但也别一直等——输出安静下来就说明这行写完了
1112
+ // (CLI 若不给链接补换行,光靠 ① 会永远等不到)。认的仍是同一段文本,
1113
+ // 不是在赌另一个 URL。
1114
+ quietTimer = setTimeout(() => { quietTimer = undefined; tryLatch(buf); }, CONFIG_QUIET_MS);
1115
+ }
1116
+ }
1117
+ // 截断放在解析之后:先解析再丢,才不会把刚到的链接连同旧输出一起切掉
1118
+ if (buf.length > CONFIG_OUTPUT_CAP)
1119
+ buf = buf.slice(-CONFIG_OUTPUT_CAP);
1120
+ };
1121
+ child.stdout?.on('data', onChunk);
1122
+ child.stderr?.on('data', onChunk);
1123
+ child.on('error', (e) => settle(null, e instanceof Error ? e.message : String(e)));
1124
+ child.on('exit', (code) => settle(code));
1125
+ }
1126
+ /**
1127
+ * 发起「配置应用」流程(页面二维码的主入口)。
1128
+ *
1129
+ * 四道闸与 startLarkCliDeviceAuth 同构:
1130
+ * ① 幂等复用:pending 会话直接返回(挡住刷新页面 / 关弹窗再开)
1131
+ * ② in-flight 合并:并发调用 await 同一个 promise(挡住双击)
1132
+ * ③ regenerate:用户显式要求时才新建
1133
+ * ④ gen 代号:新建时 +1,在飞的老子进程靠它丢弃过期结果
1134
+ *
1135
+ * 与设备流的一处**有意不同**:没有「剩余不足 X 秒就换新」——CLI 不吐有效期,
1136
+ * 我们也就无从编造倒计时;会话的终结一律由子进程退出(或 CONFIG_URL_WAIT_MS 兜底)驱动。
1137
+ */
1138
+ export async function startLarkCliConfigFlow(opts = {}) {
1139
+ const deps = {
1140
+ spawn: opts.deps?.spawn ?? defaultConfigSpawn,
1141
+ detect: opts.deps?.detect ?? (() => detectLarkCli()),
1142
+ checkAuth: opts.deps?.checkAuth ?? (() => checkLarkCliAuth()),
1143
+ makeQr: opts.deps?.makeQr ?? ((url) => defaultMakeQr(url, defaultAuthRunner, deps.log, undefined, CONFIG_QR_FILE)),
1144
+ now: opts.deps?.now ?? (() => Date.now()),
1145
+ log: opts.deps?.log ?? ((m) => console.log('[lark-cli]', m)),
1146
+ };
1147
+ // ① 幂等复用(regenerate 时跳过)
1148
+ if (!opts.regenerate && configSession?.state === 'pending') {
1149
+ return { ...configInfo(deps.now()), reused: true };
1150
+ }
1151
+ // ② 并发合并
1152
+ if (configStartInflight)
1153
+ return configStartInflight;
1154
+ const task = (async () => {
1155
+ const d = await deps.detect();
1156
+ if (!d.installed)
1157
+ return { ok: false, hint: 'install', error: 'lark-cli 未安装,请先安装后再配置应用' };
1158
+ const before = await deps.checkAuth();
1159
+ // 探测不出就无从判断该不该配——给一条能照做的出路,而不是含糊的「已配置」把用户堵死
1160
+ if (before.state === 'unknown') {
1161
+ return { ok: false, error: `无法确认飞书应用配置状态(${before.detail ?? '探测失败'}),请稍后重试` };
1162
+ }
1163
+ // 已配置时拒绝重复发起:覆盖配置是破坏性动作,会把已有应用顶掉(与 runLarkCliActionFlow 同判据)
1164
+ if (!needsLarkCliConfig(before)) {
1165
+ return { ok: false, error: '应用已配置,无需重复配置(如确需重配请在终端执行 lark-cli config init --new)' };
1166
+ }
1167
+ abandonLarkCliConfigSession(); // ③ gen++
1168
+ const s = { gen: ++configGen, state: 'pending', startedAt: deps.now() };
1169
+ configSession = s;
1170
+ const ready = makeDeferred();
1171
+ watchLarkCliConfig(s, deps, await resolveConfigCommand(), ready.resolve);
1172
+ // 等链接到位:正常情况就是一次网络往返。等不到也照常返回 pending(此时无链接),
1173
+ // 前端继续轮询 status——链接一旦到手会随轮询补上,不会丢。
1174
+ let timer;
1175
+ const timeout = new Promise((r) => { timer = setTimeout(() => r(undefined), CONFIG_START_WAIT_MS); });
1176
+ let url;
1177
+ try {
1178
+ url = await Promise.race([ready.promise, timeout]);
1179
+ }
1180
+ finally {
1181
+ if (timer)
1182
+ clearTimeout(timer);
1183
+ }
1184
+ if (configSession?.gen !== s.gen)
1185
+ return configInfo(deps.now()); // 期间被新一轮取代
1186
+ // 二维码只是展示层,生成失败不阻塞配置(降级为只显示可复制链接)
1187
+ if (url)
1188
+ s.qrDataUrl = await deps.makeQr(url).catch(() => undefined);
1189
+ return configInfo(deps.now());
1190
+ })();
1191
+ configStartInflight = task;
1192
+ try {
1193
+ return await task;
1194
+ }
1195
+ finally {
1196
+ configStartInflight = null;
1197
+ }
1198
+ }
863
1199
  /** 同一 op 两次拉起终端的最小间隔:挡住连点开出两个终端窗口 */
864
1200
  const LAUNCH_COOLDOWN_MS = 10_000;
865
1201
  let inFlight = false;
@@ -871,8 +1207,9 @@ export function resetLarkCliActionState() {
871
1207
  delete lastLaunchAt.update;
872
1208
  delete lastLaunchAt.auth;
873
1209
  delete lastLaunchAt.config;
874
- // 设备流会话也在同一进程内,跟着一起清——既有 30+ 处 beforeEach 无需逐条补
1210
+ // 设备流与配置会话也在同一进程内,跟着一起清——既有 30+ 处 beforeEach 无需逐条补
875
1211
  resetLarkCliDeviceState();
1212
+ resetLarkCliConfigState();
876
1213
  }
877
1214
  /** 构造烘焙值:全部来自 OS,不含任何用户输入;取不到包内入口只是少一道兜底,不致命 */
878
1215
  async function defaultLaunch(task, opts) {
@@ -913,6 +1250,7 @@ export async function runLarkCliActionFlow(op, deps = {}) {
913
1250
  const detect = deps.detect ?? (() => detectLarkCli());
914
1251
  const install = deps.install ?? (() => installLarkCli());
915
1252
  const installSkill = deps.installSkill ?? (() => installLarkCliSkill());
1253
+ const listSkills = deps.listSkills ?? (() => listLarkCliSkills());
916
1254
  const checkAuth = deps.checkAuth ?? (() => checkLarkCliAuth());
917
1255
  const launch = deps.launch ?? defaultLaunch;
918
1256
  const now = deps.now ?? (() => Date.now());
@@ -945,16 +1283,21 @@ export async function runLarkCliActionFlow(op, deps = {}) {
945
1283
  };
946
1284
  }
947
1285
  lastLaunchAt[op] = now();
948
- // 用户改用终端了:作废页面上那轮设备流,避免后台收尾与终端里的 auth login
949
- // 同时往 token store 写。放在 r.ok 之后而非函数入口——否则「点了更新又取消确认框」
950
- // 会把用户正在扫的二维码搞没。
1286
+ // 用户改用终端了:作废页面上那两轮会话(设备流 / 配置应用),避免后台进程与终端里的
1287
+ // lark-cli 同时往 token store、config 文件写。放在 r.ok 之后而非函数入口——否则
1288
+ // 「点了更新又取消确认框」会把用户正在扫的二维码搞没。
951
1289
  abandonLarkCliDeviceSession();
1290
+ abandonLarkCliConfigSession();
952
1291
  return { ok: true, mode: 'terminal', terminal: r.terminal, scriptPath: r.scriptPath };
953
1292
  }
954
1293
  if (op === 'skill') {
955
1294
  try {
956
1295
  const output = await installSkill();
957
- return { ok: true, mode: 'silent', output };
1296
+ // 装完扫盘列出实际落盘的名单交给前端渲染:npx 那段输出是带 ANSI 的进度表,
1297
+ // 直接展示既难读又易误导(同名散目录 / 部分失败都看不出来)。output 仍回传
1298
+ // 供排查,但剥掉 ANSI 与控制字符再给出去;扫盘失败不算安装失败,退空数组。
1299
+ const skills = await listSkills().catch(() => []);
1300
+ return { ok: true, mode: 'silent', output: stripAnsiAndBom(output), skills };
958
1301
  }
959
1302
  catch (e) {
960
1303
  return { ok: false, mode: 'silent', error: `SKILL 安装失败:${e instanceof Error ? e.message : String(e)}` };
@@ -972,7 +1315,8 @@ export async function runLarkCliActionFlow(op, deps = {}) {
972
1315
  log(`未找到可用终端(${r.reason}),降级为静默安装`);
973
1316
  try {
974
1317
  const output = await install();
975
- return { ok: true, mode: 'silent', output, reason: r.reason };
1318
+ // 降级路径的输出同样要经前端展示,先剥 ANSI(npm 的彩色输出在 <pre> 里是乱码)
1319
+ return { ok: true, mode: 'silent', output: stripAnsiAndBom(output), reason: r.reason };
976
1320
  }
977
1321
  catch (e) {
978
1322
  return { ok: false, mode: 'silent', error: `lark-cli 安装失败:${e instanceof Error ? e.message : String(e)}` };