feishu-user-plugin 1.3.10 → 1.3.12
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/.claude-plugin/plugin.json +2 -2
- package/.cursor-plugin/plugin.json +27 -0
- package/.mcpb/manifest.json +91 -0
- package/CHANGELOG.md +118 -0
- package/PRIVACY.md +105 -0
- package/README.en.md +130 -413
- package/README.md +88 -258
- package/package.json +5 -3
- package/scripts/build-mcpb.js +119 -0
- package/scripts/check-description-drift.js +73 -0
- package/scripts/check-docs-sync.js +7 -16
- package/scripts/check-mcp-registry-version.js +43 -0
- package/scripts/check-mcpb-version.js +33 -0
- package/scripts/check-scopes.js +99 -0
- package/scripts/check-tool-count.js +4 -3
- package/scripts/check-version.js +5 -0
- package/scripts/sync-claude-md.sh +3 -4
- package/scripts/sync-team-skills.sh +72 -57
- package/scripts/verify-app-name.js +64 -0
- package/skills/feishu-user-plugin/SKILL.md +3 -3
- package/skills/feishu-user-plugin/references/search.md +3 -3
- package/src/auth/credentials-monitor.js +185 -0
- package/src/auth/credentials.js +49 -0
- package/src/auth/identity-state.js +204 -0
- package/src/auth/lark-desktop.js +135 -0
- package/src/auth/uat.js +49 -35
- package/src/cli.js +87 -0
- package/src/clients/official/base.js +145 -14
- package/src/clients/official/calendar.js +3 -1
- package/src/clients/official/im.js +76 -2
- package/src/clients/official/okr.js +2 -1
- package/src/error-codes.js +40 -0
- package/src/events/lockfile.js +40 -4
- package/src/events/owner.js +11 -2
- package/src/index.js +1 -1
- package/src/logger.js +11 -5
- package/src/oauth.js +46 -10
- package/src/server.js +102 -37
- package/src/setup.js +44 -0
- package/src/test-all.js +40 -0
- package/src/test-cli-tool.js +87 -0
- package/src/test-credentials-monitor.js +124 -0
- package/src/test-display-label.js +88 -0
- package/src/test-error-codes.js +85 -0
- package/src/test-identity-state.js +172 -0
- package/src/test-lark-desktop.js +300 -0
- package/src/test-lockfile-pid.js +90 -0
- package/src/test-lru-cache.js +145 -0
- package/src/test-negative-cache.js +85 -0
- package/src/test-populate-sender-names.js +98 -0
- package/src/test-search-messages.js +101 -0
- package/src/test-send-shape.js +115 -0
- package/src/test-via-user.js +94 -0
- package/src/test-with-uat-retry.js +135 -0
- package/src/tools/_registry.js +24 -1
- package/src/tools/calendar.js +5 -5
- package/src/tools/im-read.js +52 -4
- package/src/tools/messaging-user.js +1 -1
- package/src/utils.js +83 -0
- package/scripts/generate-og-image.js +0 -39
- package/skills/feishu-user-plugin/references/CLAUDE.md +0 -523
package/src/events/lockfile.js
CHANGED
|
@@ -24,10 +24,36 @@ function _writeLockBody(fd, info) {
|
|
|
24
24
|
fs.writeSync(fd, body);
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// Probe whether `pid` is a live process. Returns true when alive (or when we
|
|
28
|
+
// can't tell for certain; we prefer "alive" on EPERM because falsely
|
|
29
|
+
// declaring an EPERM'd process dead would race-steal a lock from another
|
|
30
|
+
// user's MCP server). Returns false only when ESRCH says "no such process".
|
|
31
|
+
function _isProcessAlive(pid) {
|
|
32
|
+
if (!Number.isFinite(pid) || pid <= 0) return true; // unknown → safe default
|
|
33
|
+
try {
|
|
34
|
+
process.kill(pid, 0);
|
|
35
|
+
return true;
|
|
36
|
+
} catch (e) {
|
|
37
|
+
if (e.code === 'ESRCH') return false;
|
|
38
|
+
// EPERM — process exists but we can't signal it. Treat as alive.
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function _readPidFromLock(lockPath) {
|
|
44
|
+
try {
|
|
45
|
+
const body = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
46
|
+
if (body && typeof body.pid === 'number' && body.pid > 0) return body.pid;
|
|
47
|
+
} catch (_) { /* malformed or unreadable — caller falls back to mtime */ }
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
27
51
|
// Long-lived (owner) acquisition.
|
|
28
52
|
//
|
|
29
53
|
// Returns { release(), heartbeat() } on success.
|
|
30
|
-
// Returns null if lock active (mtime within staleMs).
|
|
54
|
+
// Returns null if lock active (mtime within staleMs AND pid still alive).
|
|
55
|
+
// v1.3.12: pid liveness check shortcircuits the 60s stale window when the
|
|
56
|
+
// holder process is definitively gone (SIGKILL'd, crashed, host reboot).
|
|
31
57
|
function acquireLongLived(lockPath, { info = {}, staleMs = 60_000 } = {}) {
|
|
32
58
|
_ensureDir(lockPath);
|
|
33
59
|
|
|
@@ -38,8 +64,18 @@ function acquireLongLived(lockPath, { info = {}, staleMs = 60_000 } = {}) {
|
|
|
38
64
|
}
|
|
39
65
|
if (stat) {
|
|
40
66
|
const ageMs = Date.now() - stat.mtimeMs;
|
|
41
|
-
|
|
42
|
-
|
|
67
|
+
const fresh = ageMs < staleMs;
|
|
68
|
+
let canSteal = !fresh;
|
|
69
|
+
if (fresh) {
|
|
70
|
+
// mtime suggests alive — verify by pid liveness. If the body has a pid
|
|
71
|
+
// and that pid is gone, the holder crashed and we can steal now.
|
|
72
|
+
const pid = _readPidFromLock(lockPath);
|
|
73
|
+
if (pid !== null && !_isProcessAlive(pid)) {
|
|
74
|
+
canSteal = true;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!canSteal) return null;
|
|
78
|
+
// Stealable — rename out of the way to make room for EXCL create.
|
|
43
79
|
const stolenPath = lockPath + '.stale-' + process.pid + '-' + Date.now();
|
|
44
80
|
try { fs.renameSync(lockPath, stolenPath); } catch (e) {
|
|
45
81
|
// Race: someone else got there first; try again from scratch.
|
|
@@ -123,4 +159,4 @@ function withMutex(lockPath, fn, { staleMs = 30_000, retries = 30, retryDelayMs
|
|
|
123
159
|
}
|
|
124
160
|
}
|
|
125
161
|
|
|
126
|
-
module.exports = { acquireLongLived, withMutex };
|
|
162
|
+
module.exports = { acquireLongLived, withMutex, _isProcessAlive };
|
package/src/events/owner.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const fs = require('fs');
|
|
10
|
-
const { acquireLongLived } = require('./lockfile');
|
|
10
|
+
const { acquireLongLived, _isProcessAlive } = require('./lockfile');
|
|
11
11
|
|
|
12
12
|
const OWNER_LOCK_FILENAME = 'ws-owner.lock';
|
|
13
13
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
@@ -43,6 +43,12 @@ function tryClaim(dir, { info = {}, force = false } = {}) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
// Read current owner info without modifying anything.
|
|
46
|
+
//
|
|
47
|
+
// v1.3.12: `alive` is now the conjunction of mtime-fresh AND pid-alive. The
|
|
48
|
+
// non-owner poll in server.js calls readOwnerInfo every 30s; under the old
|
|
49
|
+
// definition a SIGKILL'd owner kept the lock looking alive until the 60s
|
|
50
|
+
// stale window elapsed. With the pid check, takeover happens on the next
|
|
51
|
+
// poll after the crash regardless of mtime.
|
|
46
52
|
function readOwnerInfo(dir) {
|
|
47
53
|
const lockPath = _ownerLockPath(dir);
|
|
48
54
|
let body = null;
|
|
@@ -53,13 +59,16 @@ function readOwnerInfo(dir) {
|
|
|
53
59
|
} catch (_) {}
|
|
54
60
|
if (!body) return { exists: false };
|
|
55
61
|
const ageSec = mtimeMs ? Math.floor((Date.now() - mtimeMs) / 1000) : null;
|
|
62
|
+
const mtimeFresh = ageSec !== null && ageSec * 1000 < STALE_MS;
|
|
63
|
+
const pidAlive = typeof body.pid === 'number' ? _isProcessAlive(body.pid) : true;
|
|
56
64
|
return {
|
|
57
65
|
exists: true,
|
|
58
66
|
pid: body.pid,
|
|
59
67
|
start_time: body.start_time,
|
|
60
68
|
mtimeMs,
|
|
61
69
|
last_heartbeat_age_seconds: ageSec,
|
|
62
|
-
|
|
70
|
+
pid_alive: pidAlive,
|
|
71
|
+
alive: mtimeFresh && pidAlive,
|
|
63
72
|
};
|
|
64
73
|
}
|
|
65
74
|
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
require('./logger'); //
|
|
2
|
+
require('./logger').installStdoutGuard(); // redirect any stray console.log → stderr — MUST be first (MCP stdio uses stdout)
|
|
3
3
|
require('./server').main().catch((err) => {
|
|
4
4
|
console.error('Fatal:', err);
|
|
5
5
|
process.exit(1);
|
package/src/logger.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// Global stdout guard. MCP stdio uses stdout for JSON-RPC; ANY accidental write
|
|
2
2
|
// from this process or its dependencies corrupts the transport and disconnects
|
|
3
3
|
// the client. Defense-in-depth: redirect every console.log / console.info to
|
|
4
|
-
// stderr
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
// stderr.
|
|
5
|
+
//
|
|
6
|
+
// v1.3.12: the guard is now opt-in via installStdoutGuard() so the CLI tool
|
|
7
|
+
// mode (\`npx feishu-user-plugin tool ...\`) can print structured JSON to the
|
|
8
|
+
// real stdout. index.js (the MCP server entry) calls it on the first line;
|
|
9
|
+
// cli.js doesn't, except when dispatching to MCP server mode.
|
|
10
|
+
function installStdoutGuard() {
|
|
11
|
+
console.log = (...args) => console.error(...args);
|
|
12
|
+
console.info = (...args) => console.error(...args);
|
|
13
|
+
}
|
|
8
14
|
|
|
9
15
|
// Stderr-only logger for the Lark SDK (the SDK's defaultLogger.error() writes
|
|
10
16
|
// to stdout via console.log, which would also corrupt MCP stdio). Shape and
|
|
@@ -17,4 +23,4 @@ const stderrLogger = {
|
|
|
17
23
|
trace: () => {},
|
|
18
24
|
};
|
|
19
25
|
|
|
20
|
-
module.exports = { stderrLogger };
|
|
26
|
+
module.exports = { stderrLogger, installStdoutGuard };
|
package/src/oauth.js
CHANGED
|
@@ -33,11 +33,18 @@ const TARGET_PROFILE = _parseTargetProfile();
|
|
|
33
33
|
const _hasCanonical = !!credentialsModule.readCanonical();
|
|
34
34
|
let creds;
|
|
35
35
|
let profileLabel;
|
|
36
|
+
// v1.3.12 (PR #45 P2): capture targetName at module init, NOT at OAuth
|
|
37
|
+
// callback time. If we re-read getActiveProfileName() inside saveToken (as
|
|
38
|
+
// the v1.3.6 code did), a concurrent `switch_profile` or Lark Desktop
|
|
39
|
+
// account flip between "open browser for authorize" and "callback fires"
|
|
40
|
+
// would write tokens to the WRONG profile silently. The whole oauth flow
|
|
41
|
+
// is anchored to the profile name resolved here.
|
|
42
|
+
let RESOLVED_PROFILE = null;
|
|
36
43
|
if (_hasCanonical) {
|
|
37
|
-
|
|
44
|
+
RESOLVED_PROFILE = TARGET_PROFILE || credentialsModule.getActiveProfileName();
|
|
38
45
|
try {
|
|
39
|
-
creds = credentialsModule.getActiveProfileEnv(
|
|
40
|
-
profileLabel = `credentials.json::profiles[${
|
|
46
|
+
creds = credentialsModule.getActiveProfileEnv(RESOLVED_PROFILE);
|
|
47
|
+
profileLabel = `credentials.json::profiles[${RESOLVED_PROFILE}]`;
|
|
41
48
|
} catch (e) {
|
|
42
49
|
console.error(`OAuth target profile error: ${e.message}`);
|
|
43
50
|
console.error(`Available: ${credentialsModule.listProfileNames().join(', ')}`);
|
|
@@ -67,10 +74,25 @@ const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
|
|
|
67
74
|
// sheets:spreadsheet for sheet_image / sheet_file media uploads
|
|
68
75
|
// drive:file:upload narrower scope for drive/v1/files/upload_all (independent of drive:drive)
|
|
69
76
|
// v1.3.7 additions:
|
|
70
|
-
// calendar:calendar.event:
|
|
77
|
+
// calendar:calendar.event:{create,update,delete,reply} calendar write — Feishu splits
|
|
78
|
+
// "write" into 4 verbs. Using the umbrella name
|
|
79
|
+
// `calendar:calendar.event:write` makes the
|
|
80
|
+
// OAuth authorize endpoint 422-reject the whole
|
|
81
|
+
// request. scripts/check-scopes.js bans it.
|
|
71
82
|
// task:task full Task v2 read+write
|
|
72
|
-
// okr:okr.content:
|
|
73
|
-
|
|
83
|
+
// okr:okr.content:writeonly create/delete OKR progress records.
|
|
84
|
+
// Note: Feishu uses `:writeonly` (one word),
|
|
85
|
+
// not `:write` (check-scopes.js banlist).
|
|
86
|
+
// v1.3.12 additions:
|
|
87
|
+
// contact:contact.base:readonly broader contact lookup (员工通讯录基本信息)
|
|
88
|
+
// im:resource user-side image/file download from messages
|
|
89
|
+
// search:message search_messages tool — search the user's IM
|
|
90
|
+
// history (POST /open-apis/search/v2/message,
|
|
91
|
+
// UAT-only; Feishu does NOT expose bot path).
|
|
92
|
+
//
|
|
93
|
+
// To add a scope: edit this line + add a row in docs/AUTH-SETUP.md scope table.
|
|
94
|
+
// scripts/check-scopes.js enforces both in CI.
|
|
95
|
+
const SCOPES = 'offline_access auth:user.id:read im:message im:message:readonly im:chat im:chat:readonly im:resource search:message contact:user.base:readonly contact:user.id:readonly contact:contact.base:readonly docx:document drive:drive drive:file:upload bitable:app wiki:wiki:readonly wiki:wiki okr:okr:readonly okr:okr.period:readonly okr:okr.content:readonly okr:okr.content:writeonly calendar:calendar:readonly calendar:calendar.event:read calendar:calendar.event:create calendar:calendar.event:update calendar:calendar.event:delete calendar:calendar.event:reply docs:document.media:download docs:document.media:upload sheets:spreadsheet task:task';
|
|
74
96
|
|
|
75
97
|
if (!APP_ID || !APP_SECRET) {
|
|
76
98
|
console.error('Missing LARK_APP_ID or LARK_APP_SECRET.');
|
|
@@ -89,7 +111,10 @@ async function getAppInfo() {
|
|
|
89
111
|
body: JSON.stringify({ app_id: APP_ID, app_secret: APP_SECRET }),
|
|
90
112
|
});
|
|
91
113
|
const tokenData = await tokenRes.json();
|
|
92
|
-
if (!tokenData.app_access_token)
|
|
114
|
+
if (!tokenData.app_access_token) {
|
|
115
|
+
console.error(`[oauth] app_access_token request returned no token: ${JSON.stringify(tokenData)}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
93
118
|
|
|
94
119
|
// Get app info — try the direct app query first, fall back to underauditlist
|
|
95
120
|
let appName = null;
|
|
@@ -100,6 +125,15 @@ async function getAppInfo() {
|
|
|
100
125
|
appName = directData?.data?.app?.app_name;
|
|
101
126
|
|
|
102
127
|
if (!appName) {
|
|
128
|
+
// v1.3.12: 99991672 specifically means "no permission to read application
|
|
129
|
+
// info" — caused by missing tenant-side scope `application:application:self_manage`
|
|
130
|
+
// (no admin review required, see docs/AUTH-SETUP.md). Without it the
|
|
131
|
+
// sender displayLabel for bot messages falls back to "[Bot] (cli_xxx)".
|
|
132
|
+
if (directData?.code === 99991672) {
|
|
133
|
+
console.error(`[oauth] App name resolve failed (code=99991672): need application:application:self_manage scope (tenant-side, 免审). displayLabel will fall back to '[Bot] (cli_xxx)'`);
|
|
134
|
+
} else if (directData?.code && directData.code !== 0) {
|
|
135
|
+
console.error(`[oauth] App name resolve failed: code=${directData.code} msg=${directData.msg}`);
|
|
136
|
+
}
|
|
103
137
|
const listRes = await fetch('https://open.feishu.cn/open-apis/application/v6/applications/underauditlist?lang=zh_cn&page_size=1', {
|
|
104
138
|
headers: { 'Authorization': `Bearer ${tokenData.app_access_token}` },
|
|
105
139
|
});
|
|
@@ -108,7 +142,8 @@ async function getAppInfo() {
|
|
|
108
142
|
}
|
|
109
143
|
|
|
110
144
|
return { appName, tenantKey: tokenData.tenant_key };
|
|
111
|
-
} catch {
|
|
145
|
+
} catch (e) {
|
|
146
|
+
console.error(`[oauth] App name lookup threw: ${e.message}`);
|
|
112
147
|
return null;
|
|
113
148
|
}
|
|
114
149
|
}
|
|
@@ -155,8 +190,9 @@ function saveToken(tokenData) {
|
|
|
155
190
|
|
|
156
191
|
let ok = false;
|
|
157
192
|
if (_hasCanonical) {
|
|
158
|
-
|
|
159
|
-
|
|
193
|
+
// Use the profile name captured at module init, not whatever
|
|
194
|
+
// credentials.json::active is *now*. See PR #45 race condition fix.
|
|
195
|
+
ok = credentialsModule.persistProfileUpdate(RESOLVED_PROFILE, updates);
|
|
160
196
|
if (ok) console.log(`Tokens written to ${profileLabel}`);
|
|
161
197
|
} else {
|
|
162
198
|
ok = legacyConfig.persistToConfig(updates);
|
package/src/server.js
CHANGED
|
@@ -29,6 +29,8 @@ const { resolveToken } = require('./resolver');
|
|
|
29
29
|
const { listPrompts, getPrompt } = require('./prompts');
|
|
30
30
|
const credentials = require('./auth/credentials');
|
|
31
31
|
const profileRouter = require('./auth/profile-router');
|
|
32
|
+
const { createCredentialsMonitor } = require('./auth/credentials-monitor');
|
|
33
|
+
const identityState = require('./auth/identity-state');
|
|
32
34
|
|
|
33
35
|
// --- Tool modules ---
|
|
34
36
|
// Adding a new domain: create src/tools/<x>.js exporting { schemas, handlers }
|
|
@@ -80,6 +82,11 @@ let ownerHeartbeatTimer = null;
|
|
|
80
82
|
let nonOwnerPollTimer = null;
|
|
81
83
|
let _ownerStartCallbacks = [];
|
|
82
84
|
|
|
85
|
+
// Lark Desktop reactor state (v1.3.11 §A) — owned by the heartbeat callback.
|
|
86
|
+
let _lastHashMtimes = {};
|
|
87
|
+
let _lastSwitchAt = 0;
|
|
88
|
+
const _seenUnboundHashes = new Set();
|
|
89
|
+
|
|
83
90
|
function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
|
|
84
91
|
|
|
85
92
|
function _stopHeartbeat() {
|
|
@@ -151,14 +158,22 @@ function getOfficialClient() {
|
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
// Mirror of LarkOfficialClient.loadUAT() but sourced from a specific env block
|
|
154
|
-
// instead of process.env, so credentials.json profiles work uniformly.
|
|
161
|
+
// instead of process.env, so credentials.json profiles work uniformly. Also
|
|
162
|
+
// the hot-reload entry point used by credMonitor.onUatChange: when `env` has
|
|
163
|
+
// no UAT (user nuked the token), clear the in-memory copy instead of
|
|
164
|
+
// silently leaving the stale token in place.
|
|
155
165
|
function loadUATFromEnv(client, env) {
|
|
156
|
-
const token = env
|
|
157
|
-
const refresh = env
|
|
158
|
-
const expires = parseInt(env
|
|
159
|
-
if (!token)
|
|
166
|
+
const token = env?.LARK_USER_ACCESS_TOKEN || null;
|
|
167
|
+
const refresh = env?.LARK_USER_REFRESH_TOKEN || null;
|
|
168
|
+
const expires = parseInt(env?.LARK_UAT_EXPIRES || '0') || 0;
|
|
169
|
+
if (!token) {
|
|
170
|
+
client._uat = null;
|
|
171
|
+
client._uatRefresh = null;
|
|
172
|
+
client._uatExpires = 0;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
160
175
|
client._uat = token;
|
|
161
|
-
client._uatRefresh = refresh
|
|
176
|
+
client._uatRefresh = refresh;
|
|
162
177
|
client._uatExpires = expires || client._decodeTokenExpiry(token);
|
|
163
178
|
}
|
|
164
179
|
|
|
@@ -203,6 +218,9 @@ async function _claimAndStart() {
|
|
|
203
218
|
|
|
204
219
|
// Heartbeat + check active changes every 15s.
|
|
205
220
|
let lastCredMtime = _credMtime();
|
|
221
|
+
// Bootstrap baseline so the very first heartbeat doesn't trigger a switch.
|
|
222
|
+
_lastHashMtimes = require('./auth/lark-desktop').listAccountHashes()
|
|
223
|
+
.reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
|
|
206
224
|
ownerHeartbeatTimer = setInterval(() => {
|
|
207
225
|
if (ownerHandle) ownerHandle.heartbeat();
|
|
208
226
|
const m = _credMtime();
|
|
@@ -210,6 +228,12 @@ async function _claimAndStart() {
|
|
|
210
228
|
lastCredMtime = m;
|
|
211
229
|
_maybeReconfigure().catch((e) => console.error(`[feishu-user-plugin] reconfigure failed: ${e.message}`));
|
|
212
230
|
}
|
|
231
|
+
// Lark Desktop reactor (v1.3.11 §A)
|
|
232
|
+
try {
|
|
233
|
+
_runLarkDesktopReactor();
|
|
234
|
+
} catch (e) {
|
|
235
|
+
console.error(`[feishu-user-plugin] Lark reactor error: ${e.message}`);
|
|
236
|
+
}
|
|
213
237
|
// Defer-rotate check
|
|
214
238
|
try {
|
|
215
239
|
const snap = events.cursor.readSnapshot(FEISHU_HOME);
|
|
@@ -242,36 +266,75 @@ function _credMtime() {
|
|
|
242
266
|
} catch (_) { return null; }
|
|
243
267
|
}
|
|
244
268
|
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
// from
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
function
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
269
|
+
// Lark Desktop reactor (v1.3.11 §A).
|
|
270
|
+
// Called from the owner heartbeat. When the most-recently-active hash differs
|
|
271
|
+
// from the active profile's bound hash AND its mtime advanced since the last
|
|
272
|
+
// snapshot, flip credentials.json::active to the matching profile (the existing
|
|
273
|
+
// _credMtime delta on the next tick triggers _maybeReconfigure which restarts
|
|
274
|
+
// the WS client with the new profile's events list).
|
|
275
|
+
function _runLarkDesktopReactor() {
|
|
276
|
+
const ld = require('./auth/lark-desktop');
|
|
277
|
+
const out = ld.detectSwitch({
|
|
278
|
+
prevSnapshot: _lastHashMtimes,
|
|
279
|
+
lastSwitchAt: _lastSwitchAt,
|
|
280
|
+
seenUnboundHashes: _seenUnboundHashes,
|
|
281
|
+
});
|
|
282
|
+
if (out.switchTo) {
|
|
283
|
+
_lastSwitchAt = Date.now();
|
|
284
|
+
console.error(
|
|
285
|
+
`[feishu-user-plugin] Lark Desktop account changed; switching profile to ` +
|
|
286
|
+
`"${out.switchTo.profile}" (hash ${out.switchTo.hash})`
|
|
287
|
+
);
|
|
288
|
+
try { credentials.setActiveProfile(out.switchTo.profile); }
|
|
289
|
+
catch (e) { console.error(`[feishu-user-plugin] setActiveProfile failed: ${e.message}`); }
|
|
290
|
+
}
|
|
291
|
+
// Refresh snapshot regardless of switch outcome — keeps debounce + advance
|
|
292
|
+
// detection consistent on subsequent ticks.
|
|
293
|
+
_lastHashMtimes = ld.listAccountHashes()
|
|
294
|
+
.reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Cross-process credentials sync (v1.3.12 — CredentialsMonitor).
|
|
298
|
+
// One poller, multiple hooks. Each tool call entry runs `credMonitor.sync()`
|
|
299
|
+
// which:
|
|
300
|
+
// - active profile changed → flips in-memory currentProfile + clears caches
|
|
301
|
+
// - UAT field changed → reloads officialClient._uat without restart
|
|
302
|
+
// - cookie field changed → (no-op for now — userClient already re-inits
|
|
303
|
+
// on next getUserClient call when nulled)
|
|
304
|
+
// - any change → invalidates the identity-state cache so the
|
|
305
|
+
// next call re-probes
|
|
306
|
+
//
|
|
307
|
+
// This replaces v1.3.9's _syncActiveProfileFromDisk (active-only) + the
|
|
308
|
+
// "restart Claude Code to pick up new UAT" hand-off pattern.
|
|
309
|
+
const credMonitor = createCredentialsMonitor();
|
|
310
|
+
|
|
311
|
+
credMonitor.onProfileSwitch(({ to }) => {
|
|
312
|
+
if (!to || to === currentProfile) return;
|
|
263
313
|
try {
|
|
264
|
-
credentials.getActiveProfileEnv(
|
|
265
|
-
currentProfile
|
|
314
|
+
credentials.getActiveProfileEnv(to); // validate profile exists
|
|
315
|
+
console.error(`[feishu-user-plugin] active profile changed on disk: ${currentProfile} → ${to}`);
|
|
316
|
+
currentProfile = to;
|
|
266
317
|
userClient = null;
|
|
267
318
|
officialClient = null;
|
|
268
|
-
// resolver.js has a wiki-node resolution cache; clear it on profile switch
|
|
269
|
-
// so wiki nodes belonging to the previous app-id don't cross-contaminate.
|
|
270
319
|
require('./resolver').clearCache();
|
|
271
320
|
} catch (e) {
|
|
272
|
-
console.error(`[feishu-user-plugin] sync to "${
|
|
321
|
+
console.error(`[feishu-user-plugin] sync to "${to}" failed: ${e.message}; staying on "${currentProfile}"`);
|
|
273
322
|
}
|
|
274
|
-
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
credMonitor.onUatChange((env) => {
|
|
326
|
+
// Hot-reload UAT into the running officialClient. No restart needed.
|
|
327
|
+
// Routing through loadUATFromEnv keeps the field-write logic in one
|
|
328
|
+
// place — same helper used at getOfficialClient() startup.
|
|
329
|
+
if (!officialClient) return; // next getOfficialClient() reads env directly
|
|
330
|
+
loadUATFromEnv(officialClient, env);
|
|
331
|
+
identityState.invalidateIdentity(officialClient);
|
|
332
|
+
console.error('[feishu-user-plugin] UAT reloaded from credentials.json (no restart needed)');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
credMonitor.onCacheInvalidate(() => {
|
|
336
|
+
if (officialClient) identityState.invalidateIdentity(officialClient);
|
|
337
|
+
});
|
|
275
338
|
|
|
276
339
|
async function _maybeReconfigure() {
|
|
277
340
|
if (!ownerHandle || !wsServer) return;
|
|
@@ -351,9 +414,11 @@ function buildCtx() {
|
|
|
351
414
|
console.error(`[feishu-user-plugin] WARN: setActiveProfile("${n}") failed to persist to credentials.json: ${e.message}. In-memory currentProfile updated anyway, but other MCP processes won't see the switch.`);
|
|
352
415
|
}
|
|
353
416
|
}
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
|
|
417
|
+
// Run a sync so credMonitor adopts the just-written file as its
|
|
418
|
+
// baseline. The onProfileSwitch hook will see `to === currentProfile`
|
|
419
|
+
// and short-circuit; UAT/cookie hooks fire only if those fields
|
|
420
|
+
// actually differ from the prior active profile, which is harmless.
|
|
421
|
+
credMonitor.sync();
|
|
357
422
|
},
|
|
358
423
|
resolveDocId,
|
|
359
424
|
getWsServer: () => wsServer,
|
|
@@ -389,9 +454,9 @@ const server = new Server(
|
|
|
389
454
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
390
455
|
|
|
391
456
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
457
|
+
// Credentials hot-reload (v1.3.12): poll credentials.json for changes and
|
|
458
|
+
// fire registered hooks (profile / UAT / cookie / invalidate).
|
|
459
|
+
credMonitor.sync();
|
|
395
460
|
const { name, arguments: args } = request.params;
|
|
396
461
|
const handler = HANDLERS[name];
|
|
397
462
|
if (!handler) {
|
|
@@ -517,7 +582,7 @@ async function main() {
|
|
|
517
582
|
}
|
|
518
583
|
}
|
|
519
584
|
|
|
520
|
-
module.exports = { main, TOOLS, HANDLERS };
|
|
585
|
+
module.exports = { main, TOOLS, HANDLERS, buildCtx };
|
|
521
586
|
|
|
522
587
|
if (require.main === module) {
|
|
523
588
|
main().catch((err) => { console.error('Fatal:', err); process.exit(1); });
|
package/src/setup.js
CHANGED
|
@@ -28,6 +28,8 @@ function parseArgs() {
|
|
|
28
28
|
else if (argv[i] === '--force') args.force = true;
|
|
29
29
|
else if (argv[i] === '--profile' && argv[i + 1]) args.profile = argv[++i];
|
|
30
30
|
else if (argv[i] === '--activate') args.activate = true;
|
|
31
|
+
else if (argv[i] === '--bind-hash' && argv[i + 1]) args.bindHash = argv[++i];
|
|
32
|
+
else if (argv[i] === '--no-bind-hash') args.noBindHash = true;
|
|
31
33
|
}
|
|
32
34
|
return args;
|
|
33
35
|
}
|
|
@@ -238,6 +240,48 @@ async function main() {
|
|
|
238
240
|
}
|
|
239
241
|
// mode === 'preserve': credentials.json is unchanged; we only update the harness pointer.
|
|
240
242
|
|
|
243
|
+
// --- Lark Desktop hash auto-bind (v1.3.11 §A) ---
|
|
244
|
+
// Triggers on fresh / update (i.e. whenever credentials.json was just modified).
|
|
245
|
+
// Skipped via --no-bind-hash. Explicit --bind-hash overrides auto-detect.
|
|
246
|
+
if ((mode === 'fresh' || mode === 'update') && !cliArgs.noBindHash) {
|
|
247
|
+
try {
|
|
248
|
+
const larkDesktop = require('./auth/lark-desktop');
|
|
249
|
+
const hashes = larkDesktop.listAccountHashes();
|
|
250
|
+
if (hashes.length > 0) {
|
|
251
|
+
let chosenHash = cliArgs.bindHash;
|
|
252
|
+
if (!chosenHash) {
|
|
253
|
+
if (hashes.length === 1) {
|
|
254
|
+
chosenHash = hashes[0].hash;
|
|
255
|
+
console.log(`\n[Lark Desktop] Detected single account hash: ${chosenHash}`);
|
|
256
|
+
} else if (nonInteractive) {
|
|
257
|
+
chosenHash = hashes[0].hash;
|
|
258
|
+
console.log(`\n[Lark Desktop] Detected ${hashes.length} accounts; auto-binding "${targetProfile}" to most-recent: ${chosenHash}`);
|
|
259
|
+
console.log(` Other hashes (run setup --profile <name> --bind-hash <hash> to bind):`);
|
|
260
|
+
hashes.slice(1).forEach((h) => {
|
|
261
|
+
const ts = new Date(h.mtimeMs).toISOString();
|
|
262
|
+
console.log(` - ${h.hash} (last active ${ts})`);
|
|
263
|
+
});
|
|
264
|
+
} else {
|
|
265
|
+
console.log(`\n[Lark Desktop] Multiple accounts detected:`);
|
|
266
|
+
hashes.forEach((h, i) => {
|
|
267
|
+
const ts = new Date(h.mtimeMs).toISOString();
|
|
268
|
+
console.log(` ${i + 1}. ${h.hash} (last active ${ts})`);
|
|
269
|
+
});
|
|
270
|
+
const pick = (await ask(`Bind profile "${targetProfile}" to which? [1]: `)).trim() || '1';
|
|
271
|
+
const idx = parseInt(pick, 10) - 1;
|
|
272
|
+
chosenHash = (idx >= 0 && idx < hashes.length) ? hashes[idx].hash : hashes[0].hash;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
credentials.setProfileLarkHash(targetProfile, chosenHash);
|
|
276
|
+
console.log(`Bound profile "${targetProfile}" to Lark account hash ${chosenHash}`);
|
|
277
|
+
console.log(` → MCP will auto-switch to this profile when Lark Desktop activates this account.`);
|
|
278
|
+
}
|
|
279
|
+
// hashes.length === 0 → silent (Lark not installed, or non-darwin) — don't disrupt setup
|
|
280
|
+
} catch (e) {
|
|
281
|
+
console.error(`[Lark Desktop] auto-bind skipped: ${e.message}`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
241
285
|
// --- Write harness config ---
|
|
242
286
|
// Always write pointer-only env to harness configs (v1.3.9 SSOT).
|
|
243
287
|
// The harness env block only needs FEISHU_PLUGIN_PROFILE; all real creds
|
package/src/test-all.js
CHANGED
|
@@ -324,4 +324,44 @@ main().catch(console.error).finally(() => {
|
|
|
324
324
|
require('./test-events-log').run();
|
|
325
325
|
require('./test-events-cursor').run();
|
|
326
326
|
require('./test-events-owner').run();
|
|
327
|
+
require('./test-error-codes').run();
|
|
328
|
+
require('./test-identity-state').run().catch(e => {
|
|
329
|
+
console.error('identity-state: FAIL', e);
|
|
330
|
+
process.exitCode = 1;
|
|
331
|
+
});
|
|
332
|
+
require('./test-with-uat-retry').run().catch(e => {
|
|
333
|
+
console.error('with-uat-retry: FAIL', e);
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
});
|
|
336
|
+
require('./test-populate-sender-names').run().catch(e => {
|
|
337
|
+
console.error('populate-sender-names: FAIL', e);
|
|
338
|
+
process.exitCode = 1;
|
|
339
|
+
});
|
|
340
|
+
require('./test-credentials-monitor').run().catch(e => {
|
|
341
|
+
console.error('credentials-monitor: FAIL', e);
|
|
342
|
+
process.exitCode = 1;
|
|
343
|
+
});
|
|
344
|
+
require('./test-lru-cache').run().catch(e => {
|
|
345
|
+
console.error('lru-cache: FAIL', e);
|
|
346
|
+
process.exitCode = 1;
|
|
347
|
+
});
|
|
348
|
+
require('./test-lockfile-pid').run();
|
|
349
|
+
require('./test-negative-cache').run().catch(e => {
|
|
350
|
+
console.error('negative-cache: FAIL', e);
|
|
351
|
+
process.exitCode = 1;
|
|
352
|
+
});
|
|
353
|
+
require('./test-send-shape').run().catch(e => {
|
|
354
|
+
console.error('send-shape: FAIL', e);
|
|
355
|
+
process.exitCode = 1;
|
|
356
|
+
});
|
|
357
|
+
require('./test-via-user').run().catch(e => {
|
|
358
|
+
console.error('via-user: FAIL', e);
|
|
359
|
+
process.exitCode = 1;
|
|
360
|
+
});
|
|
361
|
+
require('./test-search-messages').run().catch(e => {
|
|
362
|
+
console.error('search-messages: FAIL', e);
|
|
363
|
+
process.exitCode = 1;
|
|
364
|
+
});
|
|
365
|
+
require('./test-cli-tool').run();
|
|
366
|
+
require('./test-display-label'); // standalone — runs on require, exits non-zero on fail
|
|
327
367
|
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// src/test-cli-tool.js — verify the v1.3.12 \`tool\` CLI subcommand.
|
|
2
|
+
//
|
|
3
|
+
// Spawns \`node src/cli.js tool <args>\` as child_process and asserts:
|
|
4
|
+
// - tool list prints 85 names, exit 0
|
|
5
|
+
// - tool help <known-name> prints the schema, exit 0
|
|
6
|
+
// - tool help <missing-name> prints error to stderr, exit 2
|
|
7
|
+
// - tool <unknown-name> '{}' fails with exit 2
|
|
8
|
+
// - tool help (no args) prints help to stderr, exit 2
|
|
9
|
+
//
|
|
10
|
+
// We don't actually invoke a tool here because handlers need credentials
|
|
11
|
+
// — that's covered by the integration scripts. This test just covers the
|
|
12
|
+
// CLI argv-parsing + dispatcher correctness.
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const { spawnSync } = require('child_process');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const assert = require('node:assert/strict');
|
|
19
|
+
|
|
20
|
+
const CLI = path.join(__dirname, 'cli.js');
|
|
21
|
+
|
|
22
|
+
function runCli(args) {
|
|
23
|
+
return spawnSync('node', [CLI, ...args], { encoding: 'utf8' });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function run() {
|
|
27
|
+
// --- 1. tool list — exit 0, 85 lines, all known tool names ---
|
|
28
|
+
{
|
|
29
|
+
const r = runCli(['tool', 'list']);
|
|
30
|
+
assert.equal(r.status, 0, 'tool list should exit 0');
|
|
31
|
+
const lines = r.stdout.trim().split('\n');
|
|
32
|
+
assert.equal(lines.length, 85, `tool list should print 85 names, got ${lines.length}`);
|
|
33
|
+
assert.ok(lines.includes('get_login_status'));
|
|
34
|
+
assert.ok(lines.includes('search_messages'));
|
|
35
|
+
assert.ok(lines.includes('send_as_user'));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// --- 2. tool help <known> — exit 0, contains schema ---
|
|
39
|
+
{
|
|
40
|
+
const r = runCli(['tool', 'help', 'get_login_status']);
|
|
41
|
+
assert.equal(r.status, 0, 'tool help <known> should exit 0');
|
|
42
|
+
assert.ok(r.stdout.includes('# get_login_status'));
|
|
43
|
+
assert.ok(r.stdout.includes('## inputSchema'));
|
|
44
|
+
assert.ok(r.stdout.includes('"type": "object"'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- 3. tool help <unknown> — exit 2, stderr complains ---
|
|
48
|
+
{
|
|
49
|
+
const r = runCli(['tool', 'help', 'nonexistent_tool_xyz']);
|
|
50
|
+
assert.equal(r.status, 2, 'tool help <unknown> should exit 2');
|
|
51
|
+
assert.ok(r.stderr.includes('Unknown tool'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// --- 4. tool <unknown-name> '{}' — exit 2 ---
|
|
55
|
+
{
|
|
56
|
+
const r = runCli(['tool', 'nonexistent_xyz', '{}']);
|
|
57
|
+
assert.equal(r.status, 2, 'tool <unknown> should exit 2');
|
|
58
|
+
assert.ok(r.stderr.includes('Unknown tool'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- 5. tool <name> with malformed JSON args — exit 2 ---
|
|
62
|
+
{
|
|
63
|
+
const r = runCli(['tool', 'get_login_status', 'not a json']);
|
|
64
|
+
assert.equal(r.status, 2);
|
|
65
|
+
assert.ok(r.stderr.includes('failed to parse JSON'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- 6. tool (no subcommand) — exit 2 with usage on stdout ---
|
|
69
|
+
{
|
|
70
|
+
const r = runCli(['tool']);
|
|
71
|
+
assert.equal(r.status, 2);
|
|
72
|
+
assert.ok(r.stdout.includes('npx feishu-user-plugin tool'));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- 7. tool --help — exit 0 ---
|
|
76
|
+
{
|
|
77
|
+
const r = runCli(['tool', '--help']);
|
|
78
|
+
assert.equal(r.status, 0);
|
|
79
|
+
assert.ok(r.stdout.includes('tool list'));
|
|
80
|
+
assert.ok(r.stdout.includes('tool help'));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.log('cli-tool.js: PASS');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (require.main === module) run();
|
|
87
|
+
module.exports = { run };
|