@jesonliu/lark-claudecode-bridge 1.0.3 → 1.0.4
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 +1 -1
- package/assets/web/css/main.css +13 -0
- package/assets/web/js/core.js +3 -1
- package/assets/web/js/pages/larkcli-device.js +188 -0
- package/assets/web/js/pages/overview.js +244 -5
- package/assets/web/js/ui.js +66 -2
- package/config.example.yaml +3 -0
- package/dist/bin/lcb.js +10 -0
- package/dist/bin/lcb.js.map +1 -1
- package/dist/executor/notify-server.d.ts +0 -2
- package/dist/executor/notify-server.js +3 -10
- package/dist/executor/notify-server.js.map +1 -1
- package/dist/executor/plugin-manager.d.ts +16 -0
- package/dist/executor/plugin-manager.js +40 -1
- package/dist/executor/plugin-manager.js.map +1 -1
- package/dist/gateway/card-builder.d.ts +6 -5
- package/dist/gateway/card-builder.js +10 -9
- package/dist/gateway/card-builder.js.map +1 -1
- package/dist/gateway/diff-card.d.ts +7 -7
- package/dist/gateway/diff-card.js +14 -8
- package/dist/gateway/diff-card.js.map +1 -1
- package/dist/gateway/feishu-gateway.d.ts +7 -1
- package/dist/gateway/feishu-gateway.js +8 -3
- package/dist/gateway/feishu-gateway.js.map +1 -1
- package/dist/index.js +100 -30
- package/dist/index.js.map +1 -1
- package/dist/lark-cli-manager.d.ts +336 -0
- package/dist/lark-cli-manager.js +985 -0
- package/dist/lark-cli-manager.js.map +1 -0
- package/dist/notify-sop.d.ts +1 -1
- package/dist/notify-sop.js +3 -3
- package/dist/notify-sop.js.map +1 -1
- package/dist/session/commands.d.ts +3 -0
- package/dist/session/commands.js +3 -3
- package/dist/session/commands.js.map +1 -1
- package/dist/session/plugin-command.js +18 -2
- package/dist/session/plugin-command.js.map +1 -1
- package/dist/session/session-store.d.ts +9 -2
- package/dist/session/session-store.js +9 -3
- package/dist/session/session-store.js.map +1 -1
- package/dist/util/file-tracker.d.ts +13 -6
- package/dist/util/file-tracker.js +28 -10
- package/dist/util/file-tracker.js.map +1 -1
- package/dist/util/npm.d.ts +13 -0
- package/dist/util/npm.js +44 -0
- package/dist/util/npm.js.map +1 -0
- package/dist/util/terminal-window.d.ts +111 -0
- package/dist/util/terminal-window.js +425 -0
- package/dist/util/terminal-window.js.map +1 -0
- package/dist/util/workspace-diff.d.ts +4 -7
- package/dist/util/workspace-diff.js +30 -35
- package/dist/util/workspace-diff.js.map +1 -1
- package/dist/web/server.js +94 -4
- package/dist/web/server.js.map +1 -1
- package/dist/web/update.d.ts +1 -5
- package/dist/web/update.js +2 -20
- package/dist/web/update.js.map +1 -1
- package/package.json +1 -1
- package/deploy/images/1.overview.png +0 -0
- package/deploy/images/2.apps.png +0 -0
- package/deploy/images/3.workspaces.png +0 -0
- package/deploy/images/4.claude.png +0 -0
- package/deploy/images/5.permissions.png +0 -0
- package/deploy/images/6.slash.png +0 -0
- package/deploy/images/7.plugins.png +0 -0
- package/deploy/images/8.skills.png +0 -0
- package/deploy/images/9.mcp.png +0 -0
|
@@ -0,0 +1,985 @@
|
|
|
1
|
+
// 飞书官方 CLI(@larksuite/cli,命令 lark-cli)自动管理:深度拥抱飞书——
|
|
2
|
+
// lcb start 时检测,缺失则后台自动安装(npm install -g,尊重 .npmrc 镜像/代理),
|
|
3
|
+
// 失败仅告警不阻断桥接器;Web 配置页提供状态查询 / 手动安装更新(/api/lark-cli)。
|
|
4
|
+
//
|
|
5
|
+
// 对齐官方安装四步(open.feishu.cn 安装指南):① npm 装包 → ② 装 SKILL
|
|
6
|
+
// (npx skills add https://open.feishu.cn,官方标"必需",教 AI 工具怎么用 lark-cli)
|
|
7
|
+
// → ③ `lark-cli config init --new` 配置应用 → ④ `lark-cli auth login --recommend` 登录。
|
|
8
|
+
// SKILL 只装到 ~/.claude/skills(skills CLI 不支持自定义目标目录);managed 会话的可见性
|
|
9
|
+
// 由既有 bridgeUserSkills() 在 lcb start 时 junction 桥接保证,本模块不做任何拷贝。
|
|
10
|
+
//
|
|
11
|
+
// 关于登录:lcb 仍然不代管凭证,但**负责把用户送到能完成授权的地方**——
|
|
12
|
+
// 手动安装/更新改为拉起真实终端窗口跑 `npm i -g` + `config init --new`(未配置时)+
|
|
13
|
+
// `lark-cli auth login --recommend`,用户能看见输出、并在同一个窗口里顺势完成
|
|
14
|
+
// device flow 授权(拉不起终端则安装降级静默;config/auth 是交互式向导,不降级)。
|
|
15
|
+
// 授权状态经 `auth status --json` 探测后在概览页展示,未授权时给「去终端授权」入口。
|
|
16
|
+
//
|
|
17
|
+
// Agent 上下文隔离:lark-cli 会按 HERMES_HOME / OPENCLAW_HOME / LARK_CHANNEL 三个环境变量
|
|
18
|
+
// 自动进入「Agent 凭证绑定」分支(要求 config bind 而拒绝 config init)。bridge 是
|
|
19
|
+
// 飞书+ClaudeCode 专用进程,与这些 Agent 无关——lcb start 入口统一剔除(见 bin/lcb.ts),
|
|
20
|
+
// 本模块探测也走剔除后的 env,保证 lark-cli 始终走标准 init/login 路径。
|
|
21
|
+
import { execFile } from 'node:child_process';
|
|
22
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
23
|
+
import { homedir, tmpdir } from 'node:os';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { runNpm, runNpx } from './util/npm.js';
|
|
26
|
+
import { launchInTerminal } from './util/terminal-window.js';
|
|
27
|
+
/** 飞书官方 CLI 的 npm 包名(命令名 lark-cli;注意 @larksuiteoapi 作用域是 lark-mcp,勿混淆) */
|
|
28
|
+
export const LARK_CLI_PACKAGE = '@larksuite/cli';
|
|
29
|
+
/** 飞书 CLI 官方 SKILL 的来源 URL(`npx skills add` 的 well-known discovery 入口) */
|
|
30
|
+
export const LARK_CLI_SKILL_SOURCE = 'https://open.feishu.cn';
|
|
31
|
+
/**
|
|
32
|
+
* 触发 lark-cli「Agent 上下文」分支的环境变量(来源:`lark-cli config bind --help` 自述
|
|
33
|
+
* --source 由 OPENCLAW_HOME / HERMES_HOME / LARK_CHANNEL 自动探测)。实测本机设了
|
|
34
|
+
* HERMES_HOME 时 auth status 报 "hermes not bound"、config init 被拒——与 bridge 无关的
|
|
35
|
+
* 上下文不能劫持标准安装流程,入口与探测处统一剔除。
|
|
36
|
+
*/
|
|
37
|
+
export const AGENT_CONTEXT_ENV_KEYS = ['HERMES_HOME', 'OPENCLAW_HOME', 'LARK_CHANNEL'];
|
|
38
|
+
/** 返回剔除 Agent 上下文变量后的 env 副本(不改传入对象;纯函数可测) */
|
|
39
|
+
export function stripAgentContextEnv(env) {
|
|
40
|
+
const copy = { ...env };
|
|
41
|
+
for (const k of AGENT_CONTEXT_ENV_KEYS)
|
|
42
|
+
delete copy[k];
|
|
43
|
+
return copy;
|
|
44
|
+
}
|
|
45
|
+
/** 纯函数(可测):从 `lark-cli --version` 输出解析语义化版本(如 1.2.3 / 1.2.3-beta.1) */
|
|
46
|
+
export function parseLarkCliVersion(out) {
|
|
47
|
+
// 遇空白 / 引号 / 逗号 / 花括号即止——同一条正则也用于宽松场景,避免把 JSON 尾巴吞进版本号
|
|
48
|
+
const m = out.match(/(\d+\.\d+\.\d+[^\s"',}]*)/);
|
|
49
|
+
return m?.[1];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 纯函数(可测):从 `npm ls -g @larksuite/cli --json` 输出解析已装版本。
|
|
53
|
+
* JSON.parse 的结果可能是 null(字面量 "null" 也合法),必须挡住非对象再取属性
|
|
54
|
+
*/
|
|
55
|
+
export function parseNpmLsVersion(out) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(out);
|
|
58
|
+
if (!parsed || typeof parsed !== 'object')
|
|
59
|
+
return undefined;
|
|
60
|
+
const v = parsed
|
|
61
|
+
.dependencies?.[LARK_CLI_PACKAGE]?.version;
|
|
62
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** 纯函数(可测):包内 CLI 入口绝对路径(不依赖 PATH 的检测/调用锚点);实测 execFile 正反斜杠均可执行,用平台原生形式即可 */
|
|
69
|
+
export function larkCliEntryPath(prefix) {
|
|
70
|
+
return join(prefix, 'node_modules', LARK_CLI_PACKAGE, 'scripts', 'run.js');
|
|
71
|
+
}
|
|
72
|
+
/** 生产 runner:execFile promisify;win32 走 cmd /c(.cmd shim,同 npmCommand 先例) */
|
|
73
|
+
const defaultRunner = (file, args, opts) => new Promise((resolve, reject) => {
|
|
74
|
+
execFile(file, args, opts, (e, stdout) => {
|
|
75
|
+
if (e)
|
|
76
|
+
reject(e);
|
|
77
|
+
else
|
|
78
|
+
resolve(String(stdout));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
/**
|
|
82
|
+
* npm 全局前缀:同一进程内稳定,故缓存一次。
|
|
83
|
+
* 但安装完 CLI 前后会变(此前可能压根没有 node_modules),install 成功后主动弃缓存重取。
|
|
84
|
+
* 失败不缓存(下次可重试)。
|
|
85
|
+
*/
|
|
86
|
+
let prefixCache;
|
|
87
|
+
/** npm 全局安装前缀(`npm prefix -g`);供不依赖 PATH 的检测/调用锚定包位置 */
|
|
88
|
+
export async function npmGlobalPrefix(timeoutMs = 15_000) {
|
|
89
|
+
if (prefixCache)
|
|
90
|
+
return prefixCache;
|
|
91
|
+
const out = await runNpm(['prefix', '-g'], timeoutMs);
|
|
92
|
+
const p = out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean).pop();
|
|
93
|
+
if (!p)
|
|
94
|
+
throw new Error('npm prefix -g 无输出');
|
|
95
|
+
prefixCache = p;
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
/** 弃掉前缀缓存(安装成功后调用;测试用同一入口重置) */
|
|
99
|
+
export function resetNpmGlobalPrefixCache() {
|
|
100
|
+
prefixCache = undefined;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 检测 lark-cli 是否已安装 + 版本。**多路兜底,任一命中即已安装**(顺序固定,全部不依赖调用方的 PATH):
|
|
104
|
+
* ① `lark-cli` 直接调用(快路径:PATH 正常时一次到位,含非默认安装位置)
|
|
105
|
+
* ② npm 全局前缀下的包内入口 `scripts/run.js`(主力兜底:绕开 PATH,锁定本机真实安装)
|
|
106
|
+
* ③ `npm ls -g @larksuite/cli --json`(最后兜底:包在但入口跑不起来时仍能给出已装 + 版本)
|
|
107
|
+
* 历史教训:旧实现只有 ①,PATH 查不到就误报「未安装」,前端据此给出安装按钮,
|
|
108
|
+
* 用户点了反而撞上 npm 重装的文件锁(EBUSY)——已安装的 CLI 被误判成裸机。
|
|
109
|
+
*/
|
|
110
|
+
export async function detectLarkCli(timeoutMs = 10_000, runner = defaultRunner, getPrefix = npmGlobalPrefix) {
|
|
111
|
+
const opts = { timeout: timeoutMs, windowsHide: true, encoding: 'utf8', env: stripAgentContextEnv(process.env) };
|
|
112
|
+
// ① PATH 直调(win32 下 lark-cli 是 .cmd shim,execFile 无 PATHEXT 处理,复用 cmd /c 前缀)
|
|
113
|
+
const direct = process.platform === 'win32'
|
|
114
|
+
? { file: 'cmd', prefixArgs: ['/c', 'lark-cli'] }
|
|
115
|
+
: { file: 'lark-cli', prefixArgs: [] };
|
|
116
|
+
// 判据统一为「跑通 **且** 解析出版本」:只凭「没抛错」就认已安装,遇到任何不含版本号的
|
|
117
|
+
// 输出都会误报(反向的误判同样有害——页面会显示「已安装/版本未知」而不再给安装入口)
|
|
118
|
+
try {
|
|
119
|
+
const version = parseLarkCliVersion(await runner(direct.file, [...direct.prefixArgs, '--version'], opts));
|
|
120
|
+
if (version)
|
|
121
|
+
return { installed: true, version };
|
|
122
|
+
}
|
|
123
|
+
catch { /* 落 ②③ */ }
|
|
124
|
+
// ②③ 需先知道 npm 全局前缀(② 锚定包内入口,③ 锚定 npm 全局装了什么)
|
|
125
|
+
let prefix;
|
|
126
|
+
try {
|
|
127
|
+
prefix = await getPrefix();
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return { installed: false };
|
|
131
|
+
}
|
|
132
|
+
// ② 用 node 绝对路径跑包内入口(不依赖 PATH)
|
|
133
|
+
try {
|
|
134
|
+
const version = parseLarkCliVersion(await runner(process.execPath, [larkCliEntryPath(prefix), '--version'], opts));
|
|
135
|
+
if (version)
|
|
136
|
+
return { installed: true, version };
|
|
137
|
+
}
|
|
138
|
+
catch { /* 落 ③ */ }
|
|
139
|
+
// ③ npm 全局装了什么(包在但入口跑不起来时仍能给出已装 + 版本)
|
|
140
|
+
try {
|
|
141
|
+
const out = await runner('cmd', ['/c', 'npm', 'ls', '-g', LARK_CLI_PACKAGE, '--json'], opts);
|
|
142
|
+
const version = parseNpmLsVersion(out);
|
|
143
|
+
// npm ls 在某些情况下对缺失包仍输出 `{"dependencies":{}}`(退出码 0)——**无版本一律视为未安装**,
|
|
144
|
+
// 否则「未安装」会被误报成已装(版本 undefined),前端就不再提供安装入口
|
|
145
|
+
if (version)
|
|
146
|
+
return { installed: true, version };
|
|
147
|
+
}
|
|
148
|
+
catch { /* 未装/超时 */ }
|
|
149
|
+
return { installed: false };
|
|
150
|
+
}
|
|
151
|
+
/** 纯函数(可测):win32 上 npm 就地重装全局包被文件锁挡住的瞬时错误——退避重试可自愈 */
|
|
152
|
+
export function isTransientNpmLockError(message) {
|
|
153
|
+
return /EBUSY|EPERM|resource busy or locked|errno -4082|-4094/i.test(message);
|
|
154
|
+
}
|
|
155
|
+
/** 默认退避:setTimeout 包装(unref 避免拖住进程退出) */
|
|
156
|
+
function delay(ms) {
|
|
157
|
+
return new Promise((r) => { const t = setTimeout(r, ms); t.unref?.(); });
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* npm install -g @larksuite/cli(安装与升级同一命令);返回 npm 输出。
|
|
161
|
+
*
|
|
162
|
+
* win32 下「就地升级」要先 rename 旧目录再解包,杀毒实时扫描 / 并发 npm / 刚跑过的
|
|
163
|
+
* lark-cli 进程都可能瞬时锁住该目录 → EBUSY。实测该场景下 rename 未留下任何残留、
|
|
164
|
+
* 旧版本毫发无伤,只是这一次没换成——故对**锁类错误**退避重试(共 3 次,1.5s / 3s);
|
|
165
|
+
* 网络/404 等确定性失败立即抛出,不浪费用户时间。
|
|
166
|
+
*/
|
|
167
|
+
export async function installLarkCli(timeoutMs = 5 * 60_000, opts = {}) {
|
|
168
|
+
const attempts = Math.max(1, opts.attempts ?? 3);
|
|
169
|
+
const delaysMs = opts.delaysMs ?? [1500, 3000];
|
|
170
|
+
const run = opts.runNpmFn ?? runNpm;
|
|
171
|
+
const sleep = opts.sleep ?? delay;
|
|
172
|
+
const log = opts.log ?? ((msg) => console.log('[lark-cli]', msg));
|
|
173
|
+
const args = ['install', '-g', `${LARK_CLI_PACKAGE}@latest`];
|
|
174
|
+
let lastMsg = '';
|
|
175
|
+
let lockRelated = false;
|
|
176
|
+
let tried = 0; // 实际尝试次数(非锁类失败会提前放弃,报「3 次」是误导)
|
|
177
|
+
for (let i = 1; i <= attempts; i++) {
|
|
178
|
+
tried = i;
|
|
179
|
+
try {
|
|
180
|
+
const out = await run(args, timeoutMs);
|
|
181
|
+
resetNpmGlobalPrefixCache(); // 装上了 ⇒ 此前缓存的「包不存在」判断作废
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
catch (e) {
|
|
185
|
+
lastMsg = e instanceof Error ? e.message : String(e);
|
|
186
|
+
lockRelated = isTransientNpmLockError(lastMsg);
|
|
187
|
+
// 非锁类错误(网络不可达 / 404 / 无权限装全局)确定性失败,重试没有意义
|
|
188
|
+
if (!lockRelated || i >= attempts)
|
|
189
|
+
break;
|
|
190
|
+
const wait = delaysMs[Math.min(i - 1, delaysMs.length - 1)] ?? 0;
|
|
191
|
+
log(`⚠️ 安装被文件锁挡住(第 ${i}/${attempts} 次),${wait}ms 后重试…`);
|
|
192
|
+
await sleep(wait);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 建议只对锁类失败给出(网络超时却让人去关 lark-cli 终端是误导)
|
|
196
|
+
const hint = lockRelated
|
|
197
|
+
? '\n若为 Windows 文件锁冲突,请关闭正在运行的 lark-cli 终端及其它 npm 进程后重试(杀毒软件实时扫描也可能短暂占用)。'
|
|
198
|
+
: '';
|
|
199
|
+
throw new Error(`lark-cli 安装失败(尝试 ${tried} 次后放弃):${lastMsg}${hint}`);
|
|
200
|
+
}
|
|
201
|
+
/** npm view 查询最新版本(页面「有新版本」提示用) */
|
|
202
|
+
export async function checkLarkCliLatest(timeoutMs = 15_000) {
|
|
203
|
+
const out = await runNpm(['view', LARK_CLI_PACKAGE, 'version'], timeoutMs);
|
|
204
|
+
const v = (out.split(/\r?\n/).filter(Boolean).pop() ?? '').trim().replace(/^"|"$/g, '');
|
|
205
|
+
if (!/^\d+\.\d+\.\d+/.test(v))
|
|
206
|
+
throw new Error(`无法解析 npm 返回的版本号:"${v.slice(0, 50)}"`);
|
|
207
|
+
return v;
|
|
208
|
+
}
|
|
209
|
+
// ============ 官方 SKILL 安装(官方安装四步的第 ② 步,官方标注"必需") ============
|
|
210
|
+
/** 纯函数(可测):skill 目录名是否为飞书系 SKILL(feishu / lark 子串均命中) */
|
|
211
|
+
export function isFeishuSkillDirName(name) {
|
|
212
|
+
return /feishu|lark/i.test(name);
|
|
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 = {
|
|
220
|
+
exists: existsSync,
|
|
221
|
+
readdir: (d) => { try {
|
|
222
|
+
return readdirSync(d);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return [];
|
|
226
|
+
} },
|
|
227
|
+
}) {
|
|
228
|
+
if (!fs.exists(skillsDir))
|
|
229
|
+
return { installed: false };
|
|
230
|
+
for (const name of fs.readdir(skillsDir)) {
|
|
231
|
+
if (!isFeishuSkillDirName(name))
|
|
232
|
+
continue;
|
|
233
|
+
if (fs.exists(join(skillsDir, name, 'SKILL.md')))
|
|
234
|
+
return { installed: true, name };
|
|
235
|
+
}
|
|
236
|
+
return { installed: false };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* 安装飞书官方 SKILL(`npx skills add`,落盘 ~/.claude/skills):
|
|
240
|
+
* - `--copy` 必须——skills CLI 默认 symlink,Windows 上需开发者模式/管理员权限,必挂;
|
|
241
|
+
* - 装完复扫确认落盘,扫不到视为失败(npx 静默空转的场景不能谎报成功);
|
|
242
|
+
* - **不做任何拷贝**:managed 会话由既有 bridgeUserSkills() 在 lcb start 时 junction 桥接,
|
|
243
|
+
* 装完需重启桥接器生效。
|
|
244
|
+
*/
|
|
245
|
+
export async function installLarkCliSkill(timeoutMs = 3 * 60_000, opts = {}) {
|
|
246
|
+
const run = opts.runNpxFn ?? runNpx;
|
|
247
|
+
const log = opts.log ?? ((msg) => console.log('[lark-cli]', msg));
|
|
248
|
+
log(`安装飞书 CLI 官方 SKILL(npx skills add ${LARK_CLI_SKILL_SOURCE})…`);
|
|
249
|
+
const output = await run(['-y', 'skills', 'add', LARK_CLI_SKILL_SOURCE, '--skill', '*', '-g', '-a', 'claude-code', '--copy', '-y'], timeoutMs);
|
|
250
|
+
const skillsDir = opts.skillsDir ?? join(homedir(), '.claude', 'skills');
|
|
251
|
+
const after = await detectLarkCliSkill(skillsDir);
|
|
252
|
+
if (!after.installed) {
|
|
253
|
+
throw new Error(`SKILL 安装命令已执行但在 ${skillsDir} 未发现飞书 skill(npx 输出:${String(output).slice(0, 200) || '空'})`);
|
|
254
|
+
}
|
|
255
|
+
log(`✅ SKILL 安装完成:${after.name}(重启桥接器后 managed 会话可见)`);
|
|
256
|
+
return output;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 纯函数(可测):授权状态是否为「应用未配置」——决定概览页给「配置应用」还是「去终端授权」。
|
|
260
|
+
* detail 的两个来源(error.subtype / reason)实测均含 not_configured 字样。
|
|
261
|
+
*/
|
|
262
|
+
export function needsLarkCliConfig(auth) {
|
|
263
|
+
return auth.state === 'unauthorized' && /not_configured/i.test(auth.detail ?? '');
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* 核心编排(依赖注入可测):已装 → 直接返回;未装 → install → 复检;
|
|
267
|
+
* install 抛错 → warn 后返回未装态,**绝不抛出**(桥接器启动不能被外部工具安装失败拖垮)
|
|
268
|
+
*/
|
|
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 };
|
|
283
|
+
}
|
|
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
|
+
});
|
|
297
|
+
// 官方安装第 ② 步(SKILL):启动链只告警不自动装——skills add 要走网络下载,
|
|
298
|
+
// 不能让桥接器启动背这个任务;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
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return detect;
|
|
307
|
+
}
|
|
308
|
+
/** 未授权类信号(ok:true 也可能带这些 reason —— 见 parseLarkCliAuth 的第二个实测陷阱) */
|
|
309
|
+
const NEGATIVE_REASON = /not_configured|not_authenticated|unauthenticated|no_credential|expired/i;
|
|
310
|
+
/**
|
|
311
|
+
* 纯函数(可测):取文本里第一个**大括号平衡**的 JSON 对象。
|
|
312
|
+
* lark-cli 的输出前后可能夹日志,且未授权时整个 JSON 走 stderr——两个流都要能扫。
|
|
313
|
+
*/
|
|
314
|
+
export function firstJsonObject(text) {
|
|
315
|
+
const start = text.indexOf('{');
|
|
316
|
+
if (start < 0)
|
|
317
|
+
return undefined;
|
|
318
|
+
let depth = 0;
|
|
319
|
+
let inStr = false;
|
|
320
|
+
let esc = false;
|
|
321
|
+
for (let i = start; i < text.length; i++) {
|
|
322
|
+
const c = text[i];
|
|
323
|
+
if (esc) {
|
|
324
|
+
esc = false;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (c === '\\') {
|
|
328
|
+
if (inStr)
|
|
329
|
+
esc = true;
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (c === '"') {
|
|
333
|
+
inStr = !inStr;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (inStr)
|
|
337
|
+
continue;
|
|
338
|
+
if (c === '{')
|
|
339
|
+
depth++;
|
|
340
|
+
else if (c === '}') {
|
|
341
|
+
depth--;
|
|
342
|
+
if (depth === 0) {
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(text.slice(start, i + 1));
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
function str(v) {
|
|
355
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
356
|
+
}
|
|
357
|
+
function pickIdentity(u) {
|
|
358
|
+
if (!u || typeof u !== 'object')
|
|
359
|
+
return undefined;
|
|
360
|
+
const o = u;
|
|
361
|
+
// 蛇形(旧格式)与驼峰(v1.0.96 identities)并存:两种形状都用同一套候选
|
|
362
|
+
return str(o.name) ?? str(o.user_name) ?? str(o.userName)
|
|
363
|
+
?? str(o.email) ?? str(o.open_id) ?? str(o.openId) ?? str(o.user_id);
|
|
364
|
+
}
|
|
365
|
+
/** 解析不出结构时的诊断线索:正文首片段(压缩空白 + 截断),供前端 tooltip 自查 */
|
|
366
|
+
function unknownDetail(raw) {
|
|
367
|
+
const text = (raw.stdout.trim() || raw.stderr.trim()).replace(/\s+/g, ' ');
|
|
368
|
+
return text ? `无法识别的输出:${text.slice(0, 120)}` : '命令没有任何输出(lark-cli 可能未正常运行)';
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* 纯函数(可测):解析 v1.0.96 的 `identities` 结构。
|
|
372
|
+
* 非该形状(无 identities / 空对象)返回 undefined——交给旧格式判定,不在这里下结论。
|
|
373
|
+
*
|
|
374
|
+
* 实测形状(已授权):`{appId, brand, defaultAs, identities:{bot:{status},user:{status:"ready",userName,...}}, identity}`
|
|
375
|
+
*/
|
|
376
|
+
function parseIdentities(v) {
|
|
377
|
+
if (!v || typeof v !== 'object')
|
|
378
|
+
return undefined;
|
|
379
|
+
const ids = v;
|
|
380
|
+
const user = ids.user;
|
|
381
|
+
if (!user || typeof user !== 'object') {
|
|
382
|
+
// 只有 bot 就绪、没有 user 键:auth login 授权的是 **user 身份**,bot ready 不等于已登录
|
|
383
|
+
if (ids.bot && typeof ids.bot === 'object') {
|
|
384
|
+
return { state: 'unauthorized', detail: '未登录用户身份(仅 bot 就绪,需执行 lark-cli auth login)' };
|
|
385
|
+
}
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
const u = user;
|
|
389
|
+
if (str(u.status) === 'ready')
|
|
390
|
+
return { state: 'authorized', identity: pickIdentity(u) };
|
|
391
|
+
return { state: 'unauthorized', detail: str(u.status) ?? str(u.message) ?? '用户身份未就绪' };
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* 纯函数(可测):从 auth status 的原始输出判定登录态。
|
|
395
|
+
*
|
|
396
|
+
* 两个**实测**陷阱(都不可靠地凭直觉推导):
|
|
397
|
+
* ① 未授权时 JSON 写在 **stderr**、退出码 **3**、stdout 空 —— 判据绝不能看退出码;
|
|
398
|
+
* ② `auth list` 未登录时返回 `{ok:true, reason:"not_configured", users:[]}` ——
|
|
399
|
+
* `ok:true` 不等于已授权,必须找正向证据(users 非空)。
|
|
400
|
+
*
|
|
401
|
+
* 反向误判同样有害:解析不出来时退 **unknown** 而非 unauthorized ——
|
|
402
|
+
* 把「不知道」说成「未授权」会让用户白跑一趟终端。
|
|
403
|
+
*/
|
|
404
|
+
export function parseLarkCliAuth(raw) {
|
|
405
|
+
const obj = firstJsonObject(raw.stdout) ?? firstJsonObject(raw.stderr);
|
|
406
|
+
if (!obj || typeof obj !== 'object')
|
|
407
|
+
return { state: 'unknown', detail: unknownDetail(raw) };
|
|
408
|
+
const o = obj;
|
|
409
|
+
// ③ v1.0.96 实测:已授权时输出 identities 结构,**没有** ok/users/reason 三个旧字段——
|
|
410
|
+
// 只认旧字段会把「已授权」误报成「授权未知」。
|
|
411
|
+
const viaIdentities = parseIdentities(o.identities);
|
|
412
|
+
if (viaIdentities)
|
|
413
|
+
return viaIdentities;
|
|
414
|
+
if (o.ok === false) {
|
|
415
|
+
const err = (o.error ?? {});
|
|
416
|
+
return { state: 'unauthorized', detail: str(err.subtype) ?? str(err.message) };
|
|
417
|
+
}
|
|
418
|
+
const reason = str(o.reason);
|
|
419
|
+
if (reason && NEGATIVE_REASON.test(reason))
|
|
420
|
+
return { state: 'unauthorized', detail: reason };
|
|
421
|
+
if (Array.isArray(o.users)) {
|
|
422
|
+
const first = o.users[0];
|
|
423
|
+
return first
|
|
424
|
+
? { state: 'authorized', identity: pickIdentity(first) }
|
|
425
|
+
: { state: 'unauthorized', detail: reason ?? '未登录任何账号' };
|
|
426
|
+
}
|
|
427
|
+
if (o.ok === true) {
|
|
428
|
+
const identity = str(o.identity) ?? str(o.user) ?? str(o.name) ?? str(o.email);
|
|
429
|
+
return identity ? { state: 'authorized', identity } : { state: 'authorized' };
|
|
430
|
+
}
|
|
431
|
+
return { state: 'unknown', detail: unknownDetail(raw) };
|
|
432
|
+
}
|
|
433
|
+
/** 双流捕获。字符串 code(ENOENT/EACCES)才算「没跑起来」→ reject;数字退出码一律 resolve 交给解析 */
|
|
434
|
+
export const defaultAuthRunner = (file, args, opts) => new Promise((resolve, reject) => {
|
|
435
|
+
execFile(file, args, opts, (e, stdout, stderr) => {
|
|
436
|
+
const code = e?.code;
|
|
437
|
+
if (e && typeof code === 'string') {
|
|
438
|
+
reject(e);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
resolve({
|
|
442
|
+
stdout: String(stdout ?? ''),
|
|
443
|
+
stderr: String(stderr ?? ''),
|
|
444
|
+
code: typeof code === 'number' ? code : null,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
/**
|
|
449
|
+
* 探测授权状态(纯本地,约百毫秒级——页面轮询靠它,因此**不加 `--verify`**:那需要联网)。
|
|
450
|
+
* 命令跑不起来时退到 node + 包内入口,与 detectLarkCli 第 ② 路同源。
|
|
451
|
+
*/
|
|
452
|
+
export async function checkLarkCliAuth(timeoutMs = 8_000, runner = defaultAuthRunner) {
|
|
453
|
+
const opts = { timeout: timeoutMs, windowsHide: true, encoding: 'utf8', env: stripAgentContextEnv(process.env) };
|
|
454
|
+
const direct = process.platform === 'win32'
|
|
455
|
+
? { file: 'cmd', prefixArgs: ['/c', 'lark-cli'] }
|
|
456
|
+
: { file: 'lark-cli', prefixArgs: [] };
|
|
457
|
+
try {
|
|
458
|
+
return parseLarkCliAuth(await runner(direct.file, [...direct.prefixArgs, 'auth', 'status', '--json'], opts));
|
|
459
|
+
}
|
|
460
|
+
catch { /* 落②:命令没跑起来 */ }
|
|
461
|
+
try {
|
|
462
|
+
const prefix = await npmGlobalPrefix();
|
|
463
|
+
return parseLarkCliAuth(await runner(process.execPath, [larkCliEntryPath(prefix), 'auth', 'status', '--json'], opts));
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
return { state: 'unknown', detail: `探测命令未能运行:${e instanceof Error ? e.message : String(e)}` };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// ============ 设备流授权(页面二维码) ============
|
|
470
|
+
//
|
|
471
|
+
// 与「拉终端」那条路并列的第二种授权方式:把 lark-cli 的 device flow 搬到配置页里,
|
|
472
|
+
// 用户直接扫码完成,不用切窗口。**比终端路径更通用**——它不经过终端探测,因此
|
|
473
|
+
// 无桌面 / SSH 环境同样可用(那条路上「去终端授权」原本是无出口的死路)。
|
|
474
|
+
//
|
|
475
|
+
// 三段式(实测 v1.0.96 的真实输出,见 parseLarkCliDeviceStart 注释):
|
|
476
|
+
// ① `auth login --recommend --no-wait --json` → device_code + verification_url(立即返回)
|
|
477
|
+
// ② `auth qrcode <url> -o qr.png` → 二维码(cwd 落在 lark-cli 的允许根内,故可落盘)
|
|
478
|
+
// ③ `auth login --device-code <code> --json` → 用户扫完后收尾换 token(阻塞轮询,放后台跑)
|
|
479
|
+
//
|
|
480
|
+
// 收尾必须后台跑:token 是 ③ 那次调用去飞书换回来并落盘的,不跑它 auth status 永远停在
|
|
481
|
+
// unauthorized;而让前端在用户扫完后触发一个阻塞请求也不可行(请求被吊住数分钟、前端
|
|
482
|
+
// 无从知道用户何时扫完)。故 ③ 在后台自行轮询,前端只轮询一个零成本的内存态端点。
|
|
483
|
+
/**
|
|
484
|
+
* 纯函数(可测):剥掉 ANSI SGR 序列与 BOM。
|
|
485
|
+
* `--json` 理论上不带色,但 lark-cli changelog #169「Correct URL formatting in login --no-wait
|
|
486
|
+
* output」说明这个位置历史上出过 URL 格式事故;ANSI 混进 JSON 字符串会直接毁掉 JSON.parse。
|
|
487
|
+
*/
|
|
488
|
+
export function stripAnsiAndBom(text) {
|
|
489
|
+
// 用 \x1b / \uFEFF 转义而非字面控制字符:后者在源码里不可见,会被编辑器或工具链悄悄吃掉
|
|
490
|
+
return text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/\uFEFF/, '');
|
|
491
|
+
}
|
|
492
|
+
/** win32 下 lark-cli 是 .cmd shim,execFile 无 PATHEXT 处理,统一走 cmd /c */
|
|
493
|
+
function larkCliDirect() {
|
|
494
|
+
return process.platform === 'win32'
|
|
495
|
+
? { file: 'cmd', prefixArgs: ['/c', 'lark-cli'] }
|
|
496
|
+
: { file: 'lark-cli', prefixArgs: [] };
|
|
497
|
+
}
|
|
498
|
+
/** 字段取值:先顶层再 data 包装——实测 login 是平铺的,但也兼容 README 的 {ok,data} 信封 */
|
|
499
|
+
function pickField(o, keys) {
|
|
500
|
+
const data = o.data && typeof o.data === 'object' ? o.data : undefined;
|
|
501
|
+
for (const k of keys) {
|
|
502
|
+
if (o[k] !== undefined)
|
|
503
|
+
return o[k];
|
|
504
|
+
if (data && data[k] !== undefined)
|
|
505
|
+
return data[k];
|
|
506
|
+
}
|
|
507
|
+
return undefined;
|
|
508
|
+
}
|
|
509
|
+
function num(v) {
|
|
510
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
511
|
+
return v;
|
|
512
|
+
if (typeof v === 'string' && v.trim()) {
|
|
513
|
+
const n = Number(v.trim());
|
|
514
|
+
if (Number.isFinite(n))
|
|
515
|
+
return n;
|
|
516
|
+
}
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
function clamp(n, lo, hi) {
|
|
520
|
+
return Math.min(hi, Math.max(lo, n));
|
|
521
|
+
}
|
|
522
|
+
/** 从 verification_url 里取 user_code(只读提取,URL 本身仍按 opaque string 原样使用) */
|
|
523
|
+
function userCodeFromUrl(url) {
|
|
524
|
+
const m = /[?&]user_code=([^&\s]+)/.exec(url);
|
|
525
|
+
return m ? m[1] : undefined;
|
|
526
|
+
}
|
|
527
|
+
/** 首个片段(压缩空白 + 截断),供失败时的诊断线索 */
|
|
528
|
+
function snippet(stdout, stderr) {
|
|
529
|
+
return (stdout.trim() || stderr.trim()).replace(/\s+/g, ' ').slice(0, 120);
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* 纯函数(可测):解析 `auth login --no-wait --json` 的输出。
|
|
533
|
+
*
|
|
534
|
+
* **实测形状(v1.0.96)——平铺,没有 ok/data 信封**,与 lark-cli README 的「JSON Output Contract」
|
|
535
|
+
* 不一致(该契约显然不覆盖本命令):
|
|
536
|
+
* {"device_code":"…","expires_in":600,"hint":"…",
|
|
537
|
+
* "verification_url":"https://accounts.feishu.cn/oauth/v1/device/verify?flow_id=…&user_code=WAFK-BYM3"}
|
|
538
|
+
* 注意实测**没有** interval、也**没有**独立的 user_code 字段(只在 URL 的 query 里)。
|
|
539
|
+
* 解析器仍对文件级信封与 verification_uri 命名做兼容,不赌单一形状。
|
|
540
|
+
*
|
|
541
|
+
* deviceCode 或 URL 任一缺失即失败——半成品会让前端显示一个扫了没反应的二维码,
|
|
542
|
+
* 比直接报错难排查十倍。
|
|
543
|
+
*/
|
|
544
|
+
export function parseLarkCliDeviceStart(raw) {
|
|
545
|
+
// 两个流都扫:与 parseLarkCliAuth 同源——lark-cli 出错时 JSON 可能整个走 stderr
|
|
546
|
+
const obj = firstJsonObject(stripAnsiAndBom(raw.stdout)) ?? firstJsonObject(stripAnsiAndBom(raw.stderr));
|
|
547
|
+
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
|
|
548
|
+
const s = snippet(raw.stdout, raw.stderr);
|
|
549
|
+
return { ok: false, error: s ? `无法识别的输出:${s}` : '命令没有任何输出(lark-cli 可能未正常运行)' };
|
|
550
|
+
}
|
|
551
|
+
const o = obj;
|
|
552
|
+
if (o.ok === false) {
|
|
553
|
+
const err = (o.error ?? {});
|
|
554
|
+
return { ok: false, error: str(err.subtype) ?? str(err.message) ?? str(err.hint) ?? 'lark-cli 未返回设备流信息' };
|
|
555
|
+
}
|
|
556
|
+
const deviceCode = str(pickField(o, ['device_code', 'deviceCode']));
|
|
557
|
+
const url = str(pickField(o, [
|
|
558
|
+
'verification_url', 'verification_uri', 'verification_uri_complete',
|
|
559
|
+
'verificationUrl', 'verificationUri',
|
|
560
|
+
]));
|
|
561
|
+
if (!deviceCode || !url) {
|
|
562
|
+
const missing = !deviceCode ? 'device_code' : '授权链接';
|
|
563
|
+
return { ok: false, error: `设备流信息不完整(缺 ${missing}):${snippet(raw.stdout, raw.stderr)}` };
|
|
564
|
+
}
|
|
565
|
+
return {
|
|
566
|
+
ok: true,
|
|
567
|
+
value: {
|
|
568
|
+
deviceCode,
|
|
569
|
+
verificationUrl: url,
|
|
570
|
+
userCode: str(pickField(o, ['user_code', 'userCode'])) ?? userCodeFromUrl(url),
|
|
571
|
+
// 实测 expires_in=600、无 interval 字段;取值做缺省与 clamp,防异常值把倒计时搞乱
|
|
572
|
+
expiresInSec: clamp(num(pickField(o, ['expires_in', 'expiresIn'])) ?? 600, 30, 3600),
|
|
573
|
+
intervalSec: clamp(num(pickField(o, ['interval'])) ?? 5, 1, 60),
|
|
574
|
+
},
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
/** 设备流失败时的人话映射(subtype → 用户能看懂的话) */
|
|
578
|
+
const DEVICE_ERR_TEXT = {
|
|
579
|
+
expired_token: '二维码已过期,请重新生成',
|
|
580
|
+
access_denied: '已拒绝本次授权',
|
|
581
|
+
authorization_pending: '授权尚未完成',
|
|
582
|
+
slow_down: '授权尚未完成',
|
|
583
|
+
};
|
|
584
|
+
function parseDeviceError(stdout, stderr) {
|
|
585
|
+
const obj = firstJsonObject(stripAnsiAndBom(stderr)) ?? firstJsonObject(stripAnsiAndBom(stdout));
|
|
586
|
+
if (obj && typeof obj === 'object') {
|
|
587
|
+
const o = obj;
|
|
588
|
+
const e = (o.error ?? {});
|
|
589
|
+
return {
|
|
590
|
+
subtype: str(e.subtype) ?? str(o.subtype),
|
|
591
|
+
message: str(e.message) ?? str(o.message) ?? str(e.hint),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return { message: snippet(stdout, stderr) || undefined };
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* 纯函数(可测):收尾进程结束后判定本次设备流的结果。
|
|
598
|
+
*
|
|
599
|
+
* **这是整个设备流最容易出错的地方**:已授权用户点「重新授权」再扫一次码时,
|
|
600
|
+
* checkAuth() 前后都是 authorized——**状态探测无法区分「本来就好」与「本次扫码完成」**。
|
|
601
|
+
* 唯一可靠的正向信号是收尾进程的退出码。
|
|
602
|
+
*
|
|
603
|
+
* 正向兜底可以有(未授权 → 已授权的跃迁必然意味着本次扫码成功),
|
|
604
|
+
* 但**反向兜底绝不能有**:wasAuthorized=true 且 exit≠0 时必须 failed,
|
|
605
|
+
* 否则会把「本来就已授权」误报成「本次扫码成功」——已授权用户一打开弹窗就会看到
|
|
606
|
+
* "✅ 授权完成",而他其实什么都没扫。
|
|
607
|
+
*/
|
|
608
|
+
export function decideDeviceFinishOutcome(a) {
|
|
609
|
+
if (a.exitCode === 0)
|
|
610
|
+
return { phase: 'done', identity: a.authAfter?.identity };
|
|
611
|
+
if (!a.wasAuthorized && a.authAfter?.state === 'authorized') {
|
|
612
|
+
return { phase: 'done', identity: a.authAfter.identity };
|
|
613
|
+
}
|
|
614
|
+
const err = parseDeviceError(a.stdout, a.stderr);
|
|
615
|
+
const mapped = err.subtype ? DEVICE_ERR_TEXT[err.subtype] : undefined;
|
|
616
|
+
return {
|
|
617
|
+
phase: err.subtype === 'expired_token' ? 'expired' : 'failed',
|
|
618
|
+
error: mapped ?? err.message ?? err.subtype ?? '授权失败',
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
let deviceSession = null;
|
|
622
|
+
let deviceGen = 0;
|
|
623
|
+
let deviceStartInflight = null;
|
|
624
|
+
/** 剩余不足此值视为已过期:与其返回一个马上失效的二维码,不如直接新建一轮 */
|
|
625
|
+
const DEVICE_MIN_REMAIN_MS = 30_000;
|
|
626
|
+
/** 收尾进程超时 = 设备码寿命 + 此余量 */
|
|
627
|
+
const DEVICE_FINISH_SLACK_MS = 30_000;
|
|
628
|
+
const DEVICE_MAX_LIFETIME_MS = 3_600_000;
|
|
629
|
+
/** 二维码临时目录(与 terminal-window.ts 的 tmp 用法同源) */
|
|
630
|
+
export function larkCliQrDir(tmpDir) {
|
|
631
|
+
return join(tmpDir ?? tmpdir(), 'lcb-larkcli-qr');
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* 唯一的出网构造点:**显式挑字段**(不是 {...session} 展开)——结构上杜绝 device_code 泄漏。
|
|
635
|
+
* 顺带做懒过期:pending 且已超期就地转 expired,且 expired 不再返回 URL/二维码。
|
|
636
|
+
*/
|
|
637
|
+
function deviceInfo(now = Date.now(), withQr = false) {
|
|
638
|
+
const s = deviceSession;
|
|
639
|
+
if (!s)
|
|
640
|
+
return { ok: true, state: 'none' };
|
|
641
|
+
if (s.state === 'pending' && now > s.expiresAt + DEVICE_FINISH_SLACK_MS)
|
|
642
|
+
s.state = 'expired';
|
|
643
|
+
const base = {
|
|
644
|
+
ok: true,
|
|
645
|
+
state: s.state,
|
|
646
|
+
userCode: s.userCode,
|
|
647
|
+
intervalSec: s.intervalSec,
|
|
648
|
+
identity: s.identity,
|
|
649
|
+
error: s.error,
|
|
650
|
+
};
|
|
651
|
+
if (s.state === 'pending') {
|
|
652
|
+
base.verificationUrl = s.verificationUrl;
|
|
653
|
+
base.expiresInSec = Math.max(0, Math.round((s.expiresAt - now) / 1000));
|
|
654
|
+
if (withQr)
|
|
655
|
+
base.qrDataUrl = s.qrDataUrl;
|
|
656
|
+
}
|
|
657
|
+
return base;
|
|
658
|
+
}
|
|
659
|
+
/** 内存态查询(零副作用、亚毫秒)——撑住前端 2s 轮询 */
|
|
660
|
+
export function getLarkCliDeviceStatus(now = Date.now()) {
|
|
661
|
+
return deviceInfo(now);
|
|
662
|
+
}
|
|
663
|
+
/** 清掉临时二维码文件(尽力而为,失败不影响流程) */
|
|
664
|
+
function removeQrFile(log) {
|
|
665
|
+
try {
|
|
666
|
+
rmSync(join(larkCliQrDir(), 'qr.png'), { force: true });
|
|
667
|
+
}
|
|
668
|
+
catch (e) {
|
|
669
|
+
log(`清理二维码临时文件失败:${e instanceof Error ? e.message : String(e)}`);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
/**
|
|
673
|
+
* 放弃当前会话(改用终端 / 新一轮发起前)。
|
|
674
|
+
* **不 kill 子进程**:Windows 上 execFile('cmd', …) 的进程树是 cmd → node → lark-cli.exe,
|
|
675
|
+
* kill 直接子进程往往留下孤儿。靠 gen 失配丢弃结果 + execFile 的 timeout 自灭即可——
|
|
676
|
+
* 孤儿最多空转到 device code 过期后自行退出。
|
|
677
|
+
*/
|
|
678
|
+
export function abandonLarkCliDeviceSession() {
|
|
679
|
+
if (!deviceSession)
|
|
680
|
+
return;
|
|
681
|
+
deviceGen++;
|
|
682
|
+
deviceSession = null;
|
|
683
|
+
removeQrFile(() => { });
|
|
684
|
+
}
|
|
685
|
+
/** 重置编排 + 设备流状态(测试用) */
|
|
686
|
+
export function resetLarkCliDeviceState() {
|
|
687
|
+
deviceSession = null;
|
|
688
|
+
deviceGen = 0;
|
|
689
|
+
deviceStartInflight = null;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* 纯函数(可测):cmd.exe 下的参数转义。
|
|
693
|
+
*
|
|
694
|
+
* **这不是洁癖,是实测踩出来的**:verification_url 必然含 `&`(`?flow_id=…&user_code=…`),
|
|
695
|
+
* 而 `cmd /c lark-cli … <url>` 会把 `&` 当成**命令分隔符**,命令被截断成两条,
|
|
696
|
+
* 二维码文件压根不生成(只留一个莫名其妙的 ENOENT)。
|
|
697
|
+
* 注意:Node 只在参数含空格/制表符时才加引号,飞书 URL 不含空格,所以 `&` 是裸奔到 cmd 的。
|
|
698
|
+
*/
|
|
699
|
+
export function cmdEscapeArg(s) {
|
|
700
|
+
return s.replace(/[&|<>^()]/g, (c) => `^${c}`);
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* 生成二维码 data URL。失败返回 undefined——**二维码只是展示层**,生成不出来就降级为
|
|
704
|
+
* 只显示可复制链接,设备流照常进行。
|
|
705
|
+
* cwd 设为专用 tmp 子目录:lark-cli 的写路径允许根是 cwd / /tmp / ~/files(实测 cwd 生效),
|
|
706
|
+
* 故 -o 用相对名即可落进允许根。
|
|
707
|
+
*/
|
|
708
|
+
export async function defaultMakeQr(url, runner = defaultAuthRunner, log = () => { }, tmpDir) {
|
|
709
|
+
const dir = larkCliQrDir(tmpDir);
|
|
710
|
+
const args = ['auth', 'qrcode', url, '-o', 'qr.png', '--size', '512'];
|
|
711
|
+
const opts = { timeout: 10_000, windowsHide: true, encoding: 'utf8', env: stripAgentContextEnv(process.env), cwd: dir };
|
|
712
|
+
try {
|
|
713
|
+
mkdirSync(dir, { recursive: true });
|
|
714
|
+
// ★ 优先走 node + 包内入口:**不经过任何 shell 重解析**,天然免疫上面那个 `&` 陷阱。
|
|
715
|
+
// 与 checkLarkCliAuth 的第 ② 路同源(绕开 PATH,锚定本机真实安装)。
|
|
716
|
+
let done = false;
|
|
717
|
+
try {
|
|
718
|
+
const prefix = await npmGlobalPrefix();
|
|
719
|
+
await runner(process.execPath, [larkCliEntryPath(prefix), ...args], opts);
|
|
720
|
+
done = true;
|
|
721
|
+
}
|
|
722
|
+
catch { /* 落 cmd 兜底 */ }
|
|
723
|
+
if (!done) {
|
|
724
|
+
// 兜底路径要转义——这里没有 node 可锚定,只能经 cmd
|
|
725
|
+
const d = larkCliDirect();
|
|
726
|
+
await runner(d.file, [...d.prefixArgs, 'auth', 'qrcode', cmdEscapeArg(url), '-o', 'qr.png', '--size', '512'], opts);
|
|
727
|
+
}
|
|
728
|
+
return `data:image/png;base64,${readFileSync(join(dir, 'qr.png')).toString('base64')}`;
|
|
729
|
+
}
|
|
730
|
+
catch (e) {
|
|
731
|
+
log(`二维码生成失败(降级为只显示链接):${e instanceof Error ? e.message : String(e)}`);
|
|
732
|
+
return undefined;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* 后台收尾:spawn `auth login --device-code <CODE>` 让它自己按 interval 轮询,
|
|
737
|
+
* 用户扫码(或超时)后自然返回。结果写回会话。
|
|
738
|
+
* 超时 = 设备码寿命 + 30s,1 小时封顶——不设的话用户不扫时这个进程会挂满整段时间。
|
|
739
|
+
*/
|
|
740
|
+
async function runLarkCliDeviceFinish(s, deps) {
|
|
741
|
+
const remain = Math.max(s.expiresAt - deps.now(), DEVICE_FINISH_SLACK_MS);
|
|
742
|
+
const timeoutMs = Math.min(remain + DEVICE_FINISH_SLACK_MS, DEVICE_MAX_LIFETIME_MS);
|
|
743
|
+
// device code 实测是 base64url 字符集(无元字符),转义对它是 no-op——纯防御:
|
|
744
|
+
// 万一将来格式变了,也不至于在 cmd 下被 `&` 截断成另一个命令
|
|
745
|
+
const args = ['auth', 'login', '--device-code', cmdEscapeArg(s.deviceCode), '--json'];
|
|
746
|
+
const opts = { timeout: timeoutMs, windowsHide: true, encoding: 'utf8', env: stripAgentContextEnv(process.env) };
|
|
747
|
+
try {
|
|
748
|
+
let raw;
|
|
749
|
+
try {
|
|
750
|
+
const d = larkCliDirect();
|
|
751
|
+
raw = await deps.finishRunner(d.file, [...d.prefixArgs, ...args], opts);
|
|
752
|
+
}
|
|
753
|
+
catch (e) {
|
|
754
|
+
if (typeof e?.code !== 'string') {
|
|
755
|
+
const prefix = await npmGlobalPrefix();
|
|
756
|
+
raw = await deps.finishRunner(process.execPath, [larkCliEntryPath(prefix), ...args], opts);
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
throw e;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
// gen 失配 = 已被新一轮取代 → 丢弃结果,绝不污染新会话
|
|
763
|
+
if (deviceSession?.gen !== s.gen)
|
|
764
|
+
return;
|
|
765
|
+
const authAfter = raw.code === 0 ? await deps.checkAuth().catch(() => undefined) : undefined;
|
|
766
|
+
const out = decideDeviceFinishOutcome({
|
|
767
|
+
exitCode: raw.code, stdout: raw.stdout, stderr: raw.stderr,
|
|
768
|
+
wasAuthorized: s.wasAuthorized, authAfter,
|
|
769
|
+
});
|
|
770
|
+
s.state = out.phase;
|
|
771
|
+
s.identity = out.identity;
|
|
772
|
+
s.error = out.error;
|
|
773
|
+
}
|
|
774
|
+
catch (e) {
|
|
775
|
+
if (deviceSession?.gen !== s.gen)
|
|
776
|
+
return;
|
|
777
|
+
// 含 execFile timeout kill:超期就说过期,否则是执行失败
|
|
778
|
+
s.state = deps.now() >= s.expiresAt ? 'expired' : 'failed';
|
|
779
|
+
s.error = e instanceof Error ? e.message : String(e);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* 发起设备流授权(页面二维码的主入口)。
|
|
784
|
+
*
|
|
785
|
+
* 四道闸挡住「每点一次就多一个 flow」:
|
|
786
|
+
* ① 幂等复用:未过期的 pending 会话直接返回同一个 URL(挡住刷新页面 / 关弹窗再开)
|
|
787
|
+
* ② in-flight 合并:并发调用 await 同一个 promise(挡住双击)
|
|
788
|
+
* ③ regenerate:用户显式要求时才新建
|
|
789
|
+
* ④ gen 代号:新建时 +1,在飞的老收尾进程靠它丢弃过期结果
|
|
790
|
+
*/
|
|
791
|
+
export async function startLarkCliDeviceAuth(opts = {}) {
|
|
792
|
+
const deps = {
|
|
793
|
+
runner: opts.deps?.runner ?? defaultAuthRunner,
|
|
794
|
+
finishRunner: opts.deps?.finishRunner ?? defaultAuthRunner,
|
|
795
|
+
detect: opts.deps?.detect ?? (() => detectLarkCli()),
|
|
796
|
+
checkAuth: opts.deps?.checkAuth ?? (() => checkLarkCliAuth()),
|
|
797
|
+
makeQr: opts.deps?.makeQr ?? ((url) => defaultMakeQr(url, opts.deps?.runner ?? defaultAuthRunner, deps.log)),
|
|
798
|
+
now: opts.deps?.now ?? (() => Date.now()),
|
|
799
|
+
log: opts.deps?.log ?? ((m) => console.log('[lark-cli]', m)),
|
|
800
|
+
};
|
|
801
|
+
const now = deps.now();
|
|
802
|
+
// ① 幂等复用(regenerate 时跳过)
|
|
803
|
+
if (!opts.regenerate && deviceSession?.state === 'pending' && deviceSession.expiresAt - now > DEVICE_MIN_REMAIN_MS) {
|
|
804
|
+
return { ...deviceInfo(now, true), reused: true };
|
|
805
|
+
}
|
|
806
|
+
// ② 并发合并
|
|
807
|
+
if (deviceStartInflight)
|
|
808
|
+
return deviceStartInflight;
|
|
809
|
+
const task = (async () => {
|
|
810
|
+
const d = await deps.detect();
|
|
811
|
+
if (!d.installed) {
|
|
812
|
+
return { ok: false, hint: 'install', error: 'lark-cli 未安装,请先安装后再授权' };
|
|
813
|
+
}
|
|
814
|
+
// 未配置应用时 --no-wait 必然失败:提前拦,给出准确引导而不是一个费解的 API 错误
|
|
815
|
+
const before = await deps.checkAuth();
|
|
816
|
+
if (needsLarkCliConfig(before)) {
|
|
817
|
+
return { ok: false, hint: 'config', error: '飞书应用尚未配置,无法发起扫码授权(请先点「配置应用」)' };
|
|
818
|
+
}
|
|
819
|
+
abandonLarkCliDeviceSession(); // ③ gen++
|
|
820
|
+
const args = ['auth', 'login', '--recommend', '--no-wait', '--json'];
|
|
821
|
+
const runnerOpts = { timeout: 20_000, windowsHide: true, encoding: 'utf8', env: stripAgentContextEnv(process.env) };
|
|
822
|
+
let raw;
|
|
823
|
+
try {
|
|
824
|
+
const direct = larkCliDirect();
|
|
825
|
+
raw = await deps.runner(direct.file, [...direct.prefixArgs, ...args], runnerOpts);
|
|
826
|
+
}
|
|
827
|
+
catch {
|
|
828
|
+
try {
|
|
829
|
+
const prefix = await npmGlobalPrefix();
|
|
830
|
+
raw = await deps.runner(process.execPath, [larkCliEntryPath(prefix), ...args], runnerOpts);
|
|
831
|
+
}
|
|
832
|
+
catch (e) {
|
|
833
|
+
return { ok: false, error: `设备流命令未能运行:${e instanceof Error ? e.message : String(e)}` };
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
const parsed = parseLarkCliDeviceStart(raw);
|
|
837
|
+
if (!parsed.ok)
|
|
838
|
+
return { ok: false, error: parsed.error };
|
|
839
|
+
const s = {
|
|
840
|
+
gen: ++deviceGen,
|
|
841
|
+
state: 'pending',
|
|
842
|
+
deviceCode: parsed.value.deviceCode,
|
|
843
|
+
verificationUrl: parsed.value.verificationUrl,
|
|
844
|
+
userCode: parsed.value.userCode,
|
|
845
|
+
expiresAt: now + parsed.value.expiresInSec * 1000,
|
|
846
|
+
intervalSec: parsed.value.intervalSec,
|
|
847
|
+
wasAuthorized: before.state === 'authorized',
|
|
848
|
+
};
|
|
849
|
+
deviceSession = s;
|
|
850
|
+
// 二维码只是展示层,生成失败不阻塞授权
|
|
851
|
+
s.qrDataUrl = await deps.makeQr(s.verificationUrl).catch(() => undefined);
|
|
852
|
+
void runLarkCliDeviceFinish(s, deps); // ④ 后台收尾,不 await
|
|
853
|
+
return deviceInfo(deps.now(), true);
|
|
854
|
+
})();
|
|
855
|
+
deviceStartInflight = task;
|
|
856
|
+
try {
|
|
857
|
+
return await task;
|
|
858
|
+
}
|
|
859
|
+
finally {
|
|
860
|
+
deviceStartInflight = null;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/** 同一 op 两次拉起终端的最小间隔:挡住连点开出两个终端窗口 */
|
|
864
|
+
const LAUNCH_COOLDOWN_MS = 10_000;
|
|
865
|
+
let inFlight = false;
|
|
866
|
+
const lastLaunchAt = {};
|
|
867
|
+
/** 重置编排状态(测试用)。进程内单例:并发 npm 必撞 Windows 文件锁,这里必须互斥 */
|
|
868
|
+
export function resetLarkCliActionState() {
|
|
869
|
+
inFlight = false;
|
|
870
|
+
delete lastLaunchAt.install;
|
|
871
|
+
delete lastLaunchAt.update;
|
|
872
|
+
delete lastLaunchAt.auth;
|
|
873
|
+
delete lastLaunchAt.config;
|
|
874
|
+
// 设备流会话也在同一进程内,跟着一起清——既有 30+ 处 beforeEach 无需逐条补
|
|
875
|
+
resetLarkCliDeviceState();
|
|
876
|
+
}
|
|
877
|
+
/** 构造烘焙值:全部来自 OS,不含任何用户输入;取不到包内入口只是少一道兜底,不致命 */
|
|
878
|
+
async function defaultLaunch(task, opts) {
|
|
879
|
+
const bake = { pathEnv: process.env.PATH, nodePath: process.execPath };
|
|
880
|
+
try {
|
|
881
|
+
bake.larkEntry = larkCliEntryPath(await npmGlobalPrefix());
|
|
882
|
+
}
|
|
883
|
+
catch { /* 见上 */ }
|
|
884
|
+
return launchInTerminal(task, { bake, needConfig: opts?.needConfig });
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* install 任务的 config 段开关:未配置则插 `config init --new`(官方第 ③ 步顺势完成)。
|
|
888
|
+
* 探测失败保守按「已配置」处理(false)——宁可让用户装完点「配置应用」按钮,
|
|
889
|
+
* 也不对偶发超时的已配置机器弹出重复向导。
|
|
890
|
+
*/
|
|
891
|
+
async function needConfigQuietly(checkAuth, log) {
|
|
892
|
+
try {
|
|
893
|
+
return needsLarkCliConfig(await checkAuth());
|
|
894
|
+
}
|
|
895
|
+
catch (e) {
|
|
896
|
+
log(`授权态探测失败(按已配置处理,跳过 config init 段):${e instanceof Error ? e.message : String(e)}`);
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* 安装/更新/授权/配置/SKILL 五条动作的统一编排。
|
|
902
|
+
*
|
|
903
|
+
* - install / update:优先拉起终端(用户能看见 npm 输出并顺势完成配置与授权);
|
|
904
|
+
* 拉不起终端(SSH / 无桌面)则降级静默安装,`reason` 带回降级原因。
|
|
905
|
+
* - auth / config:**不降级** —— 交互式向导没有静默等价物,拉不起终端就如实报错。
|
|
906
|
+
* config 额外要求当前确属「未配置」态(needsLarkCliConfig),已配置时拒绝重复向导。
|
|
907
|
+
* - skill:静默安装(npx skills add,无交互),不拉终端;失败仅返回错误不抛出。
|
|
908
|
+
*
|
|
909
|
+
* 互斥覆盖「探测 + 拉起」的亚秒窗口;终端存活期由 10s 冷却兜(否则用户 3 分钟后
|
|
910
|
+
* 想再授权会被自己锁死)。
|
|
911
|
+
*/
|
|
912
|
+
export async function runLarkCliActionFlow(op, deps = {}) {
|
|
913
|
+
const detect = deps.detect ?? (() => detectLarkCli());
|
|
914
|
+
const install = deps.install ?? (() => installLarkCli());
|
|
915
|
+
const installSkill = deps.installSkill ?? (() => installLarkCliSkill());
|
|
916
|
+
const checkAuth = deps.checkAuth ?? (() => checkLarkCliAuth());
|
|
917
|
+
const launch = deps.launch ?? defaultLaunch;
|
|
918
|
+
const now = deps.now ?? (() => Date.now());
|
|
919
|
+
const log = deps.log ?? ((m) => console.log('[lark-cli]', m));
|
|
920
|
+
if (inFlight)
|
|
921
|
+
return { ok: false, busy: true, mode: 'none', error: '已有 lark-cli 操作正在进行,请稍候' };
|
|
922
|
+
inFlight = true;
|
|
923
|
+
try {
|
|
924
|
+
const last = lastLaunchAt[op];
|
|
925
|
+
if (last !== undefined && now() - last < LAUNCH_COOLDOWN_MS) {
|
|
926
|
+
return { ok: false, busy: true, mode: 'none', error: '终端窗口刚刚已经打开,请等它跑完再试' };
|
|
927
|
+
}
|
|
928
|
+
if (op === 'auth' || op === 'config') {
|
|
929
|
+
const d = await detect();
|
|
930
|
+
if (!d.installed)
|
|
931
|
+
return { ok: false, mode: 'none', error: `lark-cli 未安装,请先安装后再${op === 'auth' ? '授权' : '配置'}` };
|
|
932
|
+
if (op === 'config') {
|
|
933
|
+
// 已配置时拒绝重复向导:覆盖配置是破坏性动作,且 TUI 向导会把已有 profile 顶掉
|
|
934
|
+
const auth = await checkAuth();
|
|
935
|
+
if (!needsLarkCliConfig(auth)) {
|
|
936
|
+
return { ok: false, mode: 'none', error: '应用已配置,无需重复配置(如确需重配请在终端执行 lark-cli config init --new)' };
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const r = await launch(op);
|
|
940
|
+
if (!r.ok) {
|
|
941
|
+
return {
|
|
942
|
+
ok: false,
|
|
943
|
+
mode: 'none',
|
|
944
|
+
error: `未找到可用终端(${r.reason}):请在服务器本机终端执行 ${op === 'auth' ? 'lark-cli auth login --recommend' : 'lark-cli config init --new'}`,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
lastLaunchAt[op] = now();
|
|
948
|
+
// 用户改用终端了:作废页面上那轮设备流,避免后台收尾与终端里的 auth login
|
|
949
|
+
// 同时往 token store 写。放在 r.ok 之后而非函数入口——否则「点了更新又取消确认框」
|
|
950
|
+
// 会把用户正在扫的二维码搞没。
|
|
951
|
+
abandonLarkCliDeviceSession();
|
|
952
|
+
return { ok: true, mode: 'terminal', terminal: r.terminal, scriptPath: r.scriptPath };
|
|
953
|
+
}
|
|
954
|
+
if (op === 'skill') {
|
|
955
|
+
try {
|
|
956
|
+
const output = await installSkill();
|
|
957
|
+
return { ok: true, mode: 'silent', output };
|
|
958
|
+
}
|
|
959
|
+
catch (e) {
|
|
960
|
+
return { ok: false, mode: 'silent', error: `SKILL 安装失败:${e instanceof Error ? e.message : String(e)}` };
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
const r = await launch('install', { needConfig: await needConfigQuietly(checkAuth, log) });
|
|
964
|
+
if (r.ok) {
|
|
965
|
+
lastLaunchAt[op] = now();
|
|
966
|
+
// 用户改用终端了:作废页面上那轮设备流,避免后台收尾与终端里的 auth login
|
|
967
|
+
// 同时往 token store 写。放在 r.ok 之后而非函数入口——否则「点了更新又取消确认框」
|
|
968
|
+
// 会把用户正在扫的二维码搞没。
|
|
969
|
+
abandonLarkCliDeviceSession();
|
|
970
|
+
return { ok: true, mode: 'terminal', terminal: r.terminal, scriptPath: r.scriptPath };
|
|
971
|
+
}
|
|
972
|
+
log(`未找到可用终端(${r.reason}),降级为静默安装`);
|
|
973
|
+
try {
|
|
974
|
+
const output = await install();
|
|
975
|
+
return { ok: true, mode: 'silent', output, reason: r.reason };
|
|
976
|
+
}
|
|
977
|
+
catch (e) {
|
|
978
|
+
return { ok: false, mode: 'silent', error: `lark-cli 安装失败:${e instanceof Error ? e.message : String(e)}` };
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
finally {
|
|
982
|
+
inFlight = false;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
//# sourceMappingURL=lark-cli-manager.js.map
|