feishu-user-plugin 1.3.11 → 1.3.13
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 +2 -2
- package/.mcpb/manifest.json +3 -3
- package/CHANGELOG.md +159 -8
- package/README.en.md +130 -413
- package/README.md +69 -259
- package/package.json +2 -2
- package/scripts/check-description-drift.js +73 -0
- package/scripts/check-docs-sync.js +7 -16
- package/scripts/check-scopes.js +99 -0
- package/scripts/check-tool-count.js +4 -3
- package/scripts/sync-claude-md.sh +3 -4
- 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/identity-state.js +209 -0
- package/src/auth/uat.js +49 -35
- package/src/cli.js +87 -0
- package/src/clients/official/base.js +170 -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 +65 -14
- package/src/server.js +76 -37
- package/src/test-all.js +41 -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 +177 -0
- package/src/test-lark-desktop.js +1 -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/skills/feishu-user-plugin/references/CLAUDE.md +0 -524
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
|
}
|
|
@@ -128,10 +163,16 @@ async function exchangeCode(code) {
|
|
|
128
163
|
body: JSON.stringify(body),
|
|
129
164
|
});
|
|
130
165
|
const raw = await tokenRes.text();
|
|
131
|
-
|
|
166
|
+
// v1.3.13 security followup: don't log the full raw body — it contains the
|
|
167
|
+
// bare access_token + refresh_token. Log only the http status and a hint of
|
|
168
|
+
// success/failure; the parsed token never leaves this function except via
|
|
169
|
+
// saveToken (which writes the file with 0600 perms).
|
|
170
|
+
console.log(`Token exchange HTTP ${tokenRes.status} (body ${raw.length} bytes)`);
|
|
132
171
|
let tokenData;
|
|
133
172
|
try { tokenData = JSON.parse(raw); } catch (e) {
|
|
134
|
-
|
|
173
|
+
// Parse error path: redact body in the thrown message so an upstream
|
|
174
|
+
// log line doesn't accidentally surface tokens.
|
|
175
|
+
throw new Error(`Response not JSON (HTTP ${tokenRes.status}, ${raw.length} bytes): ${raw.slice(0, 100).replace(/[A-Za-z0-9._-]{40,}/g, '<redacted>')}`);
|
|
135
176
|
}
|
|
136
177
|
if (tokenData.error) {
|
|
137
178
|
throw new Error(`${tokenData.error}: ${tokenData.error_description}`);
|
|
@@ -155,16 +196,26 @@ function saveToken(tokenData) {
|
|
|
155
196
|
|
|
156
197
|
let ok = false;
|
|
157
198
|
if (_hasCanonical) {
|
|
158
|
-
|
|
159
|
-
|
|
199
|
+
// Use the profile name captured at module init, not whatever
|
|
200
|
+
// credentials.json::active is *now*. See PR #45 race condition fix.
|
|
201
|
+
ok = credentialsModule.persistProfileUpdate(RESOLVED_PROFILE, updates);
|
|
160
202
|
if (ok) console.log(`Tokens written to ${profileLabel}`);
|
|
161
203
|
} else {
|
|
162
204
|
ok = legacyConfig.persistToConfig(updates);
|
|
163
205
|
if (ok) console.log(`Tokens written to ${profileLabel}`);
|
|
164
206
|
}
|
|
165
207
|
if (!ok) {
|
|
166
|
-
|
|
167
|
-
|
|
208
|
+
// v1.3.13 security followup: never dump full token bytes to stderr.
|
|
209
|
+
// Caller can find them by re-running OAuth or reading the credentials
|
|
210
|
+
// file. Show only the field shape so user knows what fields exist.
|
|
211
|
+
console.error('WARNING: Tokens could not be saved automatically. Re-run `npx feishu-user-plugin oauth` after fixing the config path, or check that `~/.feishu-user-plugin/credentials.json` is writable.');
|
|
212
|
+
console.error('Fields that would have been written (values redacted):');
|
|
213
|
+
for (const [k, v] of Object.entries(updates)) {
|
|
214
|
+
const preview = typeof v === 'string' && v.length > 0
|
|
215
|
+
? `${v.slice(0, 6)}…(${v.length} chars)`
|
|
216
|
+
: '<empty>';
|
|
217
|
+
console.error(` ${k}=${preview}`);
|
|
218
|
+
}
|
|
168
219
|
}
|
|
169
220
|
}
|
|
170
221
|
|
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 }
|
|
@@ -156,14 +158,22 @@ function getOfficialClient() {
|
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
// Mirror of LarkOfficialClient.loadUAT() but sourced from a specific env block
|
|
159
|
-
// 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.
|
|
160
165
|
function loadUATFromEnv(client, env) {
|
|
161
|
-
const token = env
|
|
162
|
-
const refresh = env
|
|
163
|
-
const expires = parseInt(env
|
|
164
|
-
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
|
+
}
|
|
165
175
|
client._uat = token;
|
|
166
|
-
client._uatRefresh = refresh
|
|
176
|
+
client._uatRefresh = refresh;
|
|
167
177
|
client._uatExpires = expires || client._decodeTokenExpiry(token);
|
|
168
178
|
}
|
|
169
179
|
|
|
@@ -284,36 +294,58 @@ function _runLarkDesktopReactor() {
|
|
|
284
294
|
.reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
|
|
285
295
|
}
|
|
286
296
|
|
|
287
|
-
// Cross-process
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
// Use the same setActiveProfile flow that switch_profile uses, except don't
|
|
304
|
-
// re-write credentials.json (which it already reflects the change).
|
|
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;
|
|
305
313
|
try {
|
|
306
|
-
credentials.getActiveProfileEnv(
|
|
307
|
-
currentProfile
|
|
314
|
+
credentials.getActiveProfileEnv(to); // validate profile exists
|
|
315
|
+
console.error(`[feishu-user-plugin] active profile changed on disk: ${currentProfile} → ${to}`);
|
|
316
|
+
currentProfile = to;
|
|
308
317
|
userClient = null;
|
|
309
318
|
officialClient = null;
|
|
310
|
-
// resolver.js has a wiki-node resolution cache; clear it on profile switch
|
|
311
|
-
// so wiki nodes belonging to the previous app-id don't cross-contaminate.
|
|
312
319
|
require('./resolver').clearCache();
|
|
313
320
|
} catch (e) {
|
|
314
|
-
console.error(`[feishu-user-plugin] sync to "${
|
|
321
|
+
console.error(`[feishu-user-plugin] sync to "${to}" failed: ${e.message}; staying on "${currentProfile}"`);
|
|
315
322
|
}
|
|
316
|
-
}
|
|
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.onCookieChange(() => {
|
|
336
|
+
// Cookie rotation: null the LarkUserClient singleton so the next
|
|
337
|
+
// getUserClient() call rebuilds it with the fresh cookie from env.
|
|
338
|
+
// Without this, cookie-based tools (send_to_user / search_contacts /
|
|
339
|
+
// get_login_status / send_as_user / batch_send) keep using the stale
|
|
340
|
+
// cookie until restart. PR #103 Codex P2 followup.
|
|
341
|
+
if (!userClient) return;
|
|
342
|
+
userClient = null;
|
|
343
|
+
console.error('[feishu-user-plugin] cookie rotation detected — userClient nulled, rebuilds on next tool call');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
credMonitor.onCacheInvalidate(() => {
|
|
347
|
+
if (officialClient) identityState.invalidateIdentity(officialClient);
|
|
348
|
+
});
|
|
317
349
|
|
|
318
350
|
async function _maybeReconfigure() {
|
|
319
351
|
if (!ownerHandle || !wsServer) return;
|
|
@@ -393,9 +425,11 @@ function buildCtx() {
|
|
|
393
425
|
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.`);
|
|
394
426
|
}
|
|
395
427
|
}
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
428
|
+
// Run a sync so credMonitor adopts the just-written file as its
|
|
429
|
+
// baseline. The onProfileSwitch hook will see `to === currentProfile`
|
|
430
|
+
// and short-circuit; UAT/cookie hooks fire only if those fields
|
|
431
|
+
// actually differ from the prior active profile, which is harmless.
|
|
432
|
+
credMonitor.sync();
|
|
399
433
|
},
|
|
400
434
|
resolveDocId,
|
|
401
435
|
getWsServer: () => wsServer,
|
|
@@ -431,9 +465,9 @@ const server = new Server(
|
|
|
431
465
|
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
|
432
466
|
|
|
433
467
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
|
|
468
|
+
// Credentials hot-reload (v1.3.12): poll credentials.json for changes and
|
|
469
|
+
// fire registered hooks (profile / UAT / cookie / invalidate).
|
|
470
|
+
credMonitor.sync();
|
|
437
471
|
const { name, arguments: args } = request.params;
|
|
438
472
|
const handler = HANDLERS[name];
|
|
439
473
|
if (!handler) {
|
|
@@ -545,6 +579,11 @@ async function main() {
|
|
|
545
579
|
}
|
|
546
580
|
}
|
|
547
581
|
|
|
582
|
+
// Baseline credMonitor at startup so any credential changes between server
|
|
583
|
+
// boot and the first tool call fire hooks instead of being silently absorbed
|
|
584
|
+
// by the first sync()'s baselining branch. PR #103 Codex P2 followup.
|
|
585
|
+
credMonitor.sync();
|
|
586
|
+
|
|
548
587
|
// --- Real-time events (v1.3.9 — owner-arbitrated) ---
|
|
549
588
|
if (hasApp) {
|
|
550
589
|
_claimAndStart().catch((e) => {
|
|
@@ -559,7 +598,7 @@ async function main() {
|
|
|
559
598
|
}
|
|
560
599
|
}
|
|
561
600
|
|
|
562
|
-
module.exports = { main, TOOLS, HANDLERS };
|
|
601
|
+
module.exports = { main, TOOLS, HANDLERS, buildCtx };
|
|
563
602
|
|
|
564
603
|
if (require.main === module) {
|
|
565
604
|
main().catch((err) => { console.error('Fatal:', err); process.exit(1); });
|
package/src/test-all.js
CHANGED
|
@@ -324,4 +324,45 @@ 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-lark-desktop').run();
|
|
367
|
+
require('./test-display-label'); // standalone — runs on require, exits non-zero on fail
|
|
327
368
|
});
|
|
@@ -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 };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// src/test-credentials-monitor.js — unit test for src/auth/credentials-monitor.js.
|
|
2
|
+
//
|
|
3
|
+
// Pre-v1.3.12 hot-reload was partial: server.js stat-ed credentials.json mtime
|
|
4
|
+
// and dispatched setActiveProfile() on change, but the UAT in-memory token,
|
|
5
|
+
// _userNameCache, and lockfile heartbeat never observed the change. Users had
|
|
6
|
+
// to restart Claude Code after `npx oauth` for the new UAT to take effect.
|
|
7
|
+
//
|
|
8
|
+
// CredentialsMonitor unifies the mtime + content-hash diff into a single
|
|
9
|
+
// poll triggered per tool call. Owners register hooks for the parts they
|
|
10
|
+
// care about: onUatChange / onCookieChange / onProfileSwitch / onCacheInvalidate.
|
|
11
|
+
//
|
|
12
|
+
// We test against a temporary credentials.json in a tmpdir so the test is
|
|
13
|
+
// isolated from any real ~/.feishu-user-plugin state.
|
|
14
|
+
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const assert = require('node:assert/strict');
|
|
21
|
+
const { createCredentialsMonitor } = require('./auth/credentials-monitor');
|
|
22
|
+
|
|
23
|
+
function writeCreds(dir, obj) {
|
|
24
|
+
const p = path.join(dir, 'credentials.json');
|
|
25
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', { mode: 0o600 });
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function run() {
|
|
30
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fish-monitor-'));
|
|
31
|
+
const credPath = path.join(dir, 'credentials.json');
|
|
32
|
+
|
|
33
|
+
const baseCreds = {
|
|
34
|
+
version: 1,
|
|
35
|
+
active: 'default',
|
|
36
|
+
profiles: {
|
|
37
|
+
default: {
|
|
38
|
+
LARK_APP_ID: 'cli_a',
|
|
39
|
+
LARK_USER_ACCESS_TOKEN: 'uat_v1',
|
|
40
|
+
LARK_USER_REFRESH_TOKEN: 'ref_v1',
|
|
41
|
+
LARK_COOKIE: 'cookie_v1',
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
writeCreds(dir, baseCreds);
|
|
46
|
+
|
|
47
|
+
// Inject path so monitor doesn't read real ~/.feishu-user-plugin
|
|
48
|
+
const monitor = createCredentialsMonitor({ path: credPath });
|
|
49
|
+
|
|
50
|
+
// --- 1. First sync establishes baseline; no hooks fire ---
|
|
51
|
+
let uatFired = 0, cookieFired = 0, profileFired = 0, cacheFired = 0;
|
|
52
|
+
monitor.onUatChange(() => uatFired++);
|
|
53
|
+
monitor.onCookieChange(() => cookieFired++);
|
|
54
|
+
monitor.onProfileSwitch(() => profileFired++);
|
|
55
|
+
monitor.onCacheInvalidate(() => cacheFired++);
|
|
56
|
+
|
|
57
|
+
monitor.sync();
|
|
58
|
+
assert.equal(uatFired, 0, 'baseline sync should not fire hooks');
|
|
59
|
+
assert.equal(cookieFired, 0);
|
|
60
|
+
assert.equal(profileFired, 0);
|
|
61
|
+
assert.equal(cacheFired, 0);
|
|
62
|
+
|
|
63
|
+
// --- 2. Change UAT field → onUatChange fires, others don't ---
|
|
64
|
+
// We must advance the mtime AFTER content change; on fast filesystems writing
|
|
65
|
+
// the same path repeatedly within the same ms gives identical mtime. Set
|
|
66
|
+
// explicit mtime so behaviour doesn't depend on FS clock granularity.
|
|
67
|
+
const after1 = { ...baseCreds, profiles: { default: { ...baseCreds.profiles.default, LARK_USER_ACCESS_TOKEN: 'uat_v2', LARK_USER_REFRESH_TOKEN: 'ref_v2' } } };
|
|
68
|
+
writeCreds(dir, after1);
|
|
69
|
+
fs.utimesSync(credPath, new Date(Date.now() + 1000), new Date(Date.now() + 1000));
|
|
70
|
+
monitor.sync();
|
|
71
|
+
assert.equal(uatFired, 1, 'onUatChange should fire on UAT diff');
|
|
72
|
+
assert.equal(cookieFired, 0, 'unchanged cookie → no cookie hook');
|
|
73
|
+
assert.equal(profileFired, 0, 'unchanged active → no profile hook');
|
|
74
|
+
assert.equal(cacheFired, 1, 'any change should fire onCacheInvalidate once');
|
|
75
|
+
|
|
76
|
+
// --- 3. Same content, just touch mtime → no hook fires (content hash guards) ---
|
|
77
|
+
fs.utimesSync(credPath, new Date(Date.now() + 2000), new Date(Date.now() + 2000));
|
|
78
|
+
monitor.sync();
|
|
79
|
+
assert.equal(uatFired, 1, 'touch (no content change) should not fire UAT');
|
|
80
|
+
assert.equal(cacheFired, 1);
|
|
81
|
+
|
|
82
|
+
// --- 4. Change cookie → onCookieChange fires ---
|
|
83
|
+
const after2 = { ...after1, profiles: { default: { ...after1.profiles.default, LARK_COOKIE: 'cookie_v2' } } };
|
|
84
|
+
writeCreds(dir, after2);
|
|
85
|
+
fs.utimesSync(credPath, new Date(Date.now() + 3000), new Date(Date.now() + 3000));
|
|
86
|
+
monitor.sync();
|
|
87
|
+
assert.equal(cookieFired, 1);
|
|
88
|
+
assert.equal(profileFired, 0);
|
|
89
|
+
|
|
90
|
+
// --- 5. Change active profile → onProfileSwitch fires (legacy parity) ---
|
|
91
|
+
const after3 = { ...after2, active: 'work', profiles: { default: after2.profiles.default, work: { LARK_APP_ID: 'cli_b' } } };
|
|
92
|
+
writeCreds(dir, after3);
|
|
93
|
+
fs.utimesSync(credPath, new Date(Date.now() + 4000), new Date(Date.now() + 4000));
|
|
94
|
+
monitor.sync();
|
|
95
|
+
assert.equal(profileFired, 1, 'active flip → onProfileSwitch fires');
|
|
96
|
+
|
|
97
|
+
// --- 6. Hook receives the new credentials snapshot as argument ---
|
|
98
|
+
let receivedToken = null;
|
|
99
|
+
monitor.onUatChange((snap) => { receivedToken = snap?.LARK_USER_ACCESS_TOKEN; });
|
|
100
|
+
const after4 = { ...after3, profiles: { ...after3.profiles, work: { LARK_APP_ID: 'cli_b', LARK_USER_ACCESS_TOKEN: 'uat_work_v1', LARK_USER_REFRESH_TOKEN: 'ref_work_v1' } } };
|
|
101
|
+
writeCreds(dir, after4);
|
|
102
|
+
fs.utimesSync(credPath, new Date(Date.now() + 5000), new Date(Date.now() + 5000));
|
|
103
|
+
monitor.sync();
|
|
104
|
+
assert.equal(receivedToken, 'uat_work_v1', 'UAT hook should receive the active profile env block');
|
|
105
|
+
|
|
106
|
+
// --- 7. Monitor handles missing file gracefully (no throw) ---
|
|
107
|
+
fs.unlinkSync(credPath);
|
|
108
|
+
monitor.sync(); // should not throw
|
|
109
|
+
|
|
110
|
+
// --- 8. File reappears later → next sync detects + fires ---
|
|
111
|
+
writeCreds(dir, baseCreds);
|
|
112
|
+
fs.utimesSync(credPath, new Date(Date.now() + 6000), new Date(Date.now() + 6000));
|
|
113
|
+
monitor.sync();
|
|
114
|
+
// baseCreds.active='default' diff from previous 'work' → profile change
|
|
115
|
+
// baseCreds.UAT='uat_v1' diff from previous 'uat_work_v1' → uat change
|
|
116
|
+
assert.ok(profileFired >= 2);
|
|
117
|
+
assert.ok(uatFired >= 2);
|
|
118
|
+
|
|
119
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
120
|
+
console.log('credentials-monitor.js: PASS');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (require.main === module) run().catch(e => { console.error(e); process.exit(1); });
|
|
124
|
+
module.exports = { run };
|