@ian2018cs/agenthub 0.2.4 → 0.2.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.
@@ -0,0 +1,347 @@
1
+ import express from 'express';
2
+ import { promises as fs } from 'fs';
3
+ import path from 'path';
4
+ import { spawn, exec } from 'child_process';
5
+ import { getUserPaths, getPublicPaths } from '../services/user-directories.js';
6
+
7
+ const router = express.Router();
8
+
9
+ // ─── lark-cli 套装常量 ──────────────────────────────────────────────────────
10
+
11
+ const LARK_CLI_SKILLS = [
12
+ 'lark-base', 'lark-calendar', 'lark-contact', 'lark-doc', 'lark-drive',
13
+ 'lark-event', 'lark-im', 'lark-mail', 'lark-minutes', 'lark-openapi-explorer',
14
+ 'lark-shared', 'lark-sheets', 'lark-skill-maker', 'lark-task', 'lark-vc',
15
+ 'lark-whiteboard', 'lark-wiki', 'lark-workflow-meeting-summary',
16
+ 'lark-workflow-standup-report',
17
+ ];
18
+
19
+ const LARK_CLI_REPO_URL = 'https://github.com/larksuite/cli';
20
+ const LARK_CLI_REPO_OWNER = 'larksuite';
21
+ const LARK_CLI_REPO_NAME = 'cli';
22
+
23
+ // ─── 认证策略表(便于未来扩展其他套装) ────────────────────────────────────
24
+
25
+ const AUTH_STRATEGIES = {
26
+ feishu: {
27
+ getEnv: (larkCliDir) => ({ LARKSUITE_CLI_CONFIG_DIR: larkCliDir }),
28
+ // Phase 1: --no-wait --json → 立即返回 { device_code, verification_url, expires_in }
29
+ noWaitArgs: (domain) => ['auth', 'login', '--domain', domain, '--no-wait', '--json'],
30
+ // Phase 2: --device-code <code> --json → 阻塞直到用户完成授权
31
+ deviceCodeArgs: (code) => ['auth', 'login', '--device-code', code, '--json'],
32
+ domain: 'all',
33
+ },
34
+ };
35
+
36
+ // ─── 辅助:运行命令收集 stdout,返回 JSON ──────────────────────────────────
37
+
38
+ function spawnJson(command, args, env) {
39
+ return new Promise((resolve, reject) => {
40
+ const proc = spawn(command, args, {
41
+ env: { ...process.env, ...env },
42
+ stdio: ['ignore', 'pipe', 'pipe'],
43
+ });
44
+ let stdout = '';
45
+ let stderr = '';
46
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
47
+ proc.stderr.on('data', (d) => { stderr += d.toString(); });
48
+ proc.on('close', (code) => {
49
+ try {
50
+ resolve(JSON.parse(stdout.trim()));
51
+ } catch {
52
+ reject(new Error(stderr.trim() || `exit ${code}`));
53
+ }
54
+ });
55
+ proc.on('error', reject);
56
+ });
57
+ }
58
+
59
+ async function checkLarkCliAvailable() {
60
+ return new Promise((resolve) => {
61
+ exec('which lark-cli', { timeout: 3000 }, (err) => resolve(!err));
62
+ });
63
+ }
64
+
65
+ async function checkConfig(larkCliDir) {
66
+ try {
67
+ const configPath = path.join(larkCliDir, 'config.json');
68
+ const content = await fs.readFile(configPath, 'utf8');
69
+ const parsed = JSON.parse(content);
70
+ const appId = parsed?.apps?.[0]?.appId;
71
+ return { ok: !!appId, appId: appId || null };
72
+ } catch {
73
+ return { ok: false, appId: null };
74
+ }
75
+ }
76
+
77
+ async function checkAuth(larkCliDir) {
78
+ return new Promise((resolve) => {
79
+ const proc = spawn('lark-cli', ['auth', 'status'], {
80
+ env: { ...process.env, LARKSUITE_CLI_CONFIG_DIR: larkCliDir },
81
+ stdio: ['ignore', 'pipe', 'ignore'],
82
+ });
83
+
84
+ let stdout = '';
85
+ const timer = setTimeout(() => {
86
+ proc.kill();
87
+ resolve({ ok: false, userName: null, userOpenId: null });
88
+ }, 5000);
89
+
90
+ proc.stdout.on('data', (d) => { stdout += d.toString(); });
91
+
92
+ proc.on('close', () => {
93
+ clearTimeout(timer);
94
+ try {
95
+ const parsed = JSON.parse(stdout.trim());
96
+ if (parsed.userName && parsed.tokenStatus !== 'expired') {
97
+ resolve({ ok: true, userName: parsed.userName, userOpenId: parsed.userOpenId || null });
98
+ return;
99
+ }
100
+ } catch { /* ignore */ }
101
+ resolve({ ok: false, userName: null, userOpenId: null });
102
+ });
103
+
104
+ proc.on('error', () => {
105
+ clearTimeout(timer);
106
+ resolve({ ok: false, userName: null, userOpenId: null });
107
+ });
108
+ });
109
+ }
110
+
111
+ async function checkSkills(skillsDir) {
112
+ let installed = 0;
113
+ const installedList = [];
114
+ for (const name of LARK_CLI_SKILLS) {
115
+ try {
116
+ await fs.lstat(path.join(skillsDir, name));
117
+ installed++;
118
+ installedList.push(name);
119
+ } catch {
120
+ // not installed
121
+ }
122
+ }
123
+ return { ok: installed === LARK_CLI_SKILLS.length, installed, total: LARK_CLI_SKILLS.length, installedList };
124
+ }
125
+
126
+ // ─── 端点1:GET /status ─────────────────────────────────────────────────────
127
+
128
+ router.get('/status', async (req, res) => {
129
+ try {
130
+ const userUuid = req.user.uuid;
131
+ const paths = getUserPaths(userUuid);
132
+
133
+ const larkCliInstalled = await checkLarkCliAvailable();
134
+ const envConfigured = !!(process.env.FEISHU_APP_ID && process.env.FEISHU_APP_SECRET);
135
+
136
+ const config = await checkConfig(paths.larkCliDir);
137
+ const auth = config.ok
138
+ ? await checkAuth(paths.larkCliDir)
139
+ : { ok: false, userName: null, userOpenId: null };
140
+ const skills = await checkSkills(paths.skillsDir);
141
+
142
+ const overall = config.ok && auth.ok && skills.ok ? 'installed' : 'incomplete';
143
+
144
+ res.json({ larkCliInstalled, envConfigured, config, auth, skills, overall });
145
+ } catch (err) {
146
+ console.error('[skill-suite] status error:', err);
147
+ res.status(500).json({ error: err.message });
148
+ }
149
+ });
150
+
151
+ // ─── 端点2:POST /init-config ────────────────────────────────────────────────
152
+
153
+ router.post('/init-config', async (req, res) => {
154
+ try {
155
+ const appId = process.env.FEISHU_APP_ID;
156
+ const appSecret = process.env.FEISHU_APP_SECRET;
157
+ if (!appId || !appSecret) {
158
+ return res.status(400).json({ error: '请先配置 FEISHU_APP_ID 和 FEISHU_APP_SECRET 环境变量' });
159
+ }
160
+
161
+ const userUuid = req.user.uuid;
162
+ const paths = getUserPaths(userUuid);
163
+ const configPath = path.join(paths.larkCliDir, 'config.json');
164
+
165
+ // 幂等:已有有效配置则跳过
166
+ const existing = await checkConfig(paths.larkCliDir);
167
+ if (existing.ok) {
168
+ return res.json({ success: true, alreadyExists: true, appId: existing.appId });
169
+ }
170
+
171
+ await fs.mkdir(paths.larkCliDir, { recursive: true });
172
+
173
+ const config = {
174
+ apps: [{
175
+ appId,
176
+ appSecret,
177
+ brand: 'feishu',
178
+ lang: 'zh',
179
+ }],
180
+ };
181
+ await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
182
+
183
+ res.json({ success: true, alreadyExists: false, appId });
184
+ } catch (err) {
185
+ console.error('[skill-suite] init-config error:', err);
186
+ res.status(500).json({ error: err.message });
187
+ }
188
+ });
189
+
190
+ // ─── 端点3:POST /install-skills ────────────────────────────────────────────
191
+
192
+ router.post('/install-skills', async (req, res) => {
193
+ try {
194
+ const userUuid = req.user.uuid;
195
+ const paths = getUserPaths(userUuid);
196
+ const publicPaths = getPublicPaths();
197
+
198
+ const publicRepoPath = path.join(publicPaths.skillsRepoDir, LARK_CLI_REPO_OWNER, LARK_CLI_REPO_NAME);
199
+
200
+ // 确保公共仓库已克隆
201
+ let repoExists = false;
202
+ try {
203
+ await fs.access(publicRepoPath);
204
+ repoExists = true;
205
+ } catch {
206
+ repoExists = false;
207
+ }
208
+
209
+ if (!repoExists) {
210
+ const parentDir = path.join(publicPaths.skillsRepoDir, LARK_CLI_REPO_OWNER);
211
+ await fs.mkdir(parentDir, { recursive: true });
212
+ await new Promise((resolve, reject) => {
213
+ const proc = spawn('git', ['clone', '--depth', '1', LARK_CLI_REPO_URL, publicRepoPath], {
214
+ stdio: 'inherit',
215
+ });
216
+ proc.on('close', (code) => {
217
+ if (code === 0) resolve();
218
+ else reject(new Error(`git clone failed with exit code ${code}`));
219
+ });
220
+ proc.on('error', reject);
221
+ });
222
+ }
223
+
224
+ // 确保用户的 skillsRepoDir 符号链接存在
225
+ const userRepoLinkParent = path.join(paths.skillsRepoDir, LARK_CLI_REPO_OWNER);
226
+ const userRepoLink = path.join(userRepoLinkParent, LARK_CLI_REPO_NAME);
227
+ await fs.mkdir(userRepoLinkParent, { recursive: true });
228
+ try {
229
+ await fs.lstat(userRepoLink);
230
+ } catch {
231
+ await fs.symlink(publicRepoPath, userRepoLink);
232
+ }
233
+
234
+ // 批量创建技能符号链接
235
+ await fs.mkdir(paths.skillsDir, { recursive: true });
236
+ const skillsSourceDir = path.join(publicRepoPath, 'skills');
237
+ const results = [];
238
+
239
+ for (const name of LARK_CLI_SKILLS) {
240
+ const sourcePath = path.join(skillsSourceDir, name);
241
+ const linkPath = path.join(paths.skillsDir, name);
242
+
243
+ // 检查源目录是否存在
244
+ try {
245
+ await fs.access(path.join(sourcePath, 'SKILL.md'));
246
+ } catch {
247
+ results.push({ name, ok: false, reason: 'not found in repo' });
248
+ continue;
249
+ }
250
+
251
+ // 幂等:先删除旧链接再创建
252
+ try {
253
+ await fs.lstat(linkPath);
254
+ await fs.unlink(linkPath);
255
+ } catch {
256
+ // 不存在,正常继续
257
+ }
258
+
259
+ try {
260
+ await fs.symlink(sourcePath, linkPath);
261
+ results.push({ name, ok: true });
262
+ } catch (err) {
263
+ results.push({ name, ok: false, reason: err.message });
264
+ }
265
+ }
266
+
267
+ const installed = results.filter((r) => r.ok).length;
268
+ res.json({ success: true, installed, total: LARK_CLI_SKILLS.length, results });
269
+ } catch (err) {
270
+ console.error('[skill-suite] install-skills error:', err);
271
+ res.status(500).json({ error: err.message });
272
+ }
273
+ });
274
+
275
+ // ─── 端点4:GET /auth (SSE,策略模式,两阶段 JSON)──────────────────────
276
+
277
+ router.get('/auth', async (req, res) => {
278
+ const type = req.query.type || 'feishu';
279
+ const strategy = AUTH_STRATEGIES[type];
280
+ if (!strategy) {
281
+ return res.status(400).json({ error: `Unknown auth type: ${type}` });
282
+ }
283
+
284
+ // 设置 SSE 响应头
285
+ res.setHeader('Content-Type', 'text/event-stream');
286
+ res.setHeader('Cache-Control', 'no-cache');
287
+ res.setHeader('Connection', 'keep-alive');
288
+ res.flushHeaders();
289
+
290
+ const sendEvent = (event, data) => {
291
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
292
+ };
293
+
294
+ let phase2Proc = null;
295
+
296
+ req.on('close', () => { phase2Proc?.kill(); });
297
+
298
+ try {
299
+ const userUuid = req.user.uuid;
300
+ const paths = getUserPaths(userUuid);
301
+ const env = strategy.getEnv(paths.larkCliDir);
302
+
303
+ // 检查 config 是否存在
304
+ const configCheck = await checkConfig(paths.larkCliDir);
305
+ if (!configCheck.ok) {
306
+ sendEvent('error', { message: '请先完成配置步骤' });
307
+ return res.end();
308
+ }
309
+
310
+ // Phase 1: --no-wait --json,立即获取 verification_url 和 device_code
311
+ const phase1 = await spawnJson('lark-cli', strategy.noWaitArgs(strategy.domain), env);
312
+ const { device_code, verification_url } = phase1;
313
+ sendEvent('url', { url: verification_url });
314
+
315
+ // Phase 2: --device-code,阻塞等待用户在浏览器完成授权
316
+ // 注意:--json 对 --device-code 子命令不生效(成功信息走 stderr 文本),
317
+ // 所以改为:exit code 0 = 成功,再调 auth status 取用户信息
318
+ await new Promise((resolve, reject) => {
319
+ phase2Proc = spawn('lark-cli', strategy.deviceCodeArgs(device_code), {
320
+ env: { ...process.env, ...env },
321
+ stdio: ['ignore', 'ignore', 'pipe'],
322
+ });
323
+
324
+ let stderr = '';
325
+ phase2Proc.stderr.on('data', (d) => { stderr += d.toString(); });
326
+
327
+ phase2Proc.on('close', async (code) => {
328
+ if (code === 0) {
329
+ // 授权成功,调 auth status 获取用户信息
330
+ const authInfo = await checkAuth(env.LARKSUITE_CLI_CONFIG_DIR);
331
+ sendEvent('success', { user: authInfo.userName || null });
332
+ resolve();
333
+ } else {
334
+ reject(new Error(stderr.trim() || `授权失败 (exit ${code})`));
335
+ }
336
+ });
337
+
338
+ phase2Proc.on('error', reject);
339
+ });
340
+ } catch (err) {
341
+ sendEvent('error', { message: err.message });
342
+ } finally {
343
+ res.end();
344
+ }
345
+ });
346
+
347
+ export default router;
@@ -31,6 +31,7 @@ export function getUserPaths(userUuid) {
31
31
  mcpRepoDir: path.join(DATA_DIR, 'user-data', userUuid, 'mcp-repo'),
32
32
  codexHomeDir: path.join(DATA_DIR, 'user-data', userUuid, 'codex-home'),
33
33
  geminiHomeDir: path.join(DATA_DIR, 'user-data', userUuid, 'gemini-home'),
34
+ larkCliDir: path.join(DATA_DIR, 'user-data', userUuid, '.lark-cli'),
34
35
  };
35
36
  }
36
37