feishu-user-plugin 1.3.8 → 1.3.9

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.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +12 -2
  2. package/CHANGELOG.md +50 -12
  3. package/README.md +4 -4
  4. package/package.json +9 -5
  5. package/proto/lark.proto +10 -0
  6. package/scripts/explore-card-protobuf.js +144 -0
  7. package/scripts/explore-image-minimize.js +163 -0
  8. package/scripts/generate-release-artifacts.js +318 -0
  9. package/scripts/probe-feishu-docx.js +203 -0
  10. package/scripts/sync-team-skills.sh +109 -7
  11. package/skills/feishu-user-plugin/SKILL.md +76 -4
  12. package/skills/feishu-user-plugin/references/CLAUDE.md +74 -54
  13. package/src/auth/credentials.js +36 -0
  14. package/src/cli.js +86 -45
  15. package/src/clients/user.js +15 -13
  16. package/src/events/cursor.js +103 -0
  17. package/src/events/event-buffer.js +8 -5
  18. package/src/events/event-log.js +151 -0
  19. package/src/events/index.js +8 -1
  20. package/src/events/lockfile.js +126 -0
  21. package/src/events/owner.js +73 -0
  22. package/src/events/ws-server.js +95 -25
  23. package/src/oauth.js +48 -7
  24. package/src/resolver.js +10 -0
  25. package/src/server.js +248 -29
  26. package/src/setup.js +99 -25
  27. package/src/test-all.js +12 -9
  28. package/src/test-events-cursor.js +56 -0
  29. package/src/test-events-lockfile.js +36 -0
  30. package/src/test-events-log.js +67 -0
  31. package/src/test-events-owner.js +64 -0
  32. package/src/test-fixtures/doc-blocks/sample-1.json +1256 -0
  33. package/src/test-read-doc-markdown.js +61 -0
  34. package/src/test-switch-profile.js +171 -0
  35. package/src/tools/diagnostics.js +10 -3
  36. package/src/tools/docs.js +93 -3
  37. package/src/tools/events.js +143 -33
  38. package/src/tools/messaging-bot.js +2 -3
  39. package/src/tools/messaging-user.js +23 -14
  40. package/src/tools/profile.js +12 -7
package/src/oauth.js CHANGED
@@ -14,9 +14,44 @@
14
14
 
15
15
  const http = require('http');
16
16
  const { execSync } = require('child_process');
17
- const { readCredentials, persistToConfig } = require('./config');
17
+ const credentialsModule = require('./auth/credentials');
18
+ const legacyConfig = require('./config');
18
19
 
19
- const creds = readCredentials();
20
+ // v1.3.9: profile-aware. Accepts `--profile <name>` (defaults to credentials.json::active);
21
+ // reads APP_ID/SECRET from that profile, persists UAT back to that profile.
22
+ // When credentials.json doesn't exist, falls back to legacy harness env path
23
+ // (which is what v1.3.6 → v1.3.8 did).
24
+ function _parseTargetProfile() {
25
+ const argv = process.argv.slice(2);
26
+ for (let i = 0; i < argv.length; i++) {
27
+ if (argv[i] === '--profile' && argv[i + 1]) return argv[++i];
28
+ }
29
+ return null;
30
+ }
31
+
32
+ const TARGET_PROFILE = _parseTargetProfile();
33
+ const _hasCanonical = !!credentialsModule.readCanonical();
34
+ let creds;
35
+ let profileLabel;
36
+ if (_hasCanonical) {
37
+ const targetName = TARGET_PROFILE || credentialsModule.getActiveProfileName();
38
+ try {
39
+ creds = credentialsModule.getActiveProfileEnv(targetName);
40
+ profileLabel = `credentials.json::profiles[${targetName}]`;
41
+ } catch (e) {
42
+ console.error(`OAuth target profile error: ${e.message}`);
43
+ console.error(`Available: ${credentialsModule.listProfileNames().join(', ')}`);
44
+ process.exit(1);
45
+ }
46
+ if (TARGET_PROFILE) console.log(`OAuth target profile: ${TARGET_PROFILE}`);
47
+ } else {
48
+ if (TARGET_PROFILE) {
49
+ console.error(`--profile flag given but credentials.json doesn't exist. Run \`npx feishu-user-plugin migrate --confirm\` first, or remove --profile to use legacy env path.`);
50
+ process.exit(1);
51
+ }
52
+ creds = legacyConfig.readCredentials();
53
+ profileLabel = 'harness env (legacy)';
54
+ }
20
55
  const APP_ID = creds.LARK_APP_ID;
21
56
  const APP_SECRET = creds.LARK_APP_SECRET;
22
57
  const PORT = 9997;
@@ -118,12 +153,18 @@ function saveToken(tokenData) {
118
153
  LARK_UAT_EXPIRES: String(Math.floor(Date.now() / 1000 + (typeof tokenData.expires_in === 'number' && tokenData.expires_in > 0 ? tokenData.expires_in : 7200))),
119
154
  };
120
155
 
121
- const ok = persistToConfig(updates);
156
+ let ok = false;
157
+ if (_hasCanonical) {
158
+ const targetName = TARGET_PROFILE || credentialsModule.getActiveProfileName();
159
+ ok = credentialsModule.persistProfileUpdate(targetName, updates);
160
+ if (ok) console.log(`Tokens written to ${profileLabel}`);
161
+ } else {
162
+ ok = legacyConfig.persistToConfig(updates);
163
+ if (ok) console.log(`Tokens written to ${profileLabel}`);
164
+ }
122
165
  if (!ok) {
123
- console.error('WARNING: Tokens could not be saved to config. Copy them manually:');
124
- for (const [k, v] of Object.entries(updates)) {
125
- console.error(` ${k}=${v}`);
126
- }
166
+ console.error('WARNING: Tokens could not be saved. Copy them manually:');
167
+ for (const [k, v] of Object.entries(updates)) console.error(` ${k}=${v}`);
127
168
  }
128
169
  }
129
170
 
package/src/resolver.js CHANGED
@@ -144,8 +144,18 @@ async function resolveToken(input, official) {
144
144
  return r.obj_token;
145
145
  }
146
146
 
147
+ /**
148
+ * Clear the wiki-node resolution cache. Called by the profile-sync hook in
149
+ * server.js when the active profile changes, so that wiki nodes belonging to
150
+ * the previous profile's app credentials don't poison lookups for the new one.
151
+ */
152
+ function clearCache() {
153
+ _cache.clear();
154
+ }
155
+
147
156
  module.exports = {
148
157
  parseFeishuInput,
149
158
  resolveToObj,
150
159
  resolveToken,
160
+ clearCache,
151
161
  };
package/src/server.js CHANGED
@@ -13,6 +13,8 @@
13
13
  // - Config discovery / persistence: src/config/*.
14
14
 
15
15
  const path = require('path');
16
+ const fs = require('fs');
17
+ const os = require('os');
16
18
  const { Server } = require('@modelcontextprotocol/sdk/server/index.js');
17
19
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
18
20
  const { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, GetPromptRequestSchema } = require('@modelcontextprotocol/sdk/types.js');
@@ -65,9 +67,28 @@ const HANDLERS = Object.fromEntries(TOOL_MODULES.flatMap((m) => Object.entries(m
65
67
  // When credentials.json exists, switching also persists the active field so
66
68
  // cross-process MCP servers see the same active profile after restart.
67
69
 
70
+ const events = require('./events');
71
+
72
+ const FEISHU_HOME = path.join(os.homedir(), '.feishu-user-plugin');
73
+ const EVENTS_LOG_PATH = path.join(FEISHU_HOME, 'events.jsonl');
74
+
68
75
  let userClient = null;
69
76
  let officialClient = null;
70
77
  let wsServer = null;
78
+ let ownerHandle = null; // returned by tryClaim when isOwner
79
+ let ownerHeartbeatTimer = null;
80
+ let nonOwnerPollTimer = null;
81
+ let _ownerStartCallbacks = [];
82
+
83
+ function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
84
+
85
+ function _stopHeartbeat() {
86
+ if (ownerHeartbeatTimer) { clearInterval(ownerHeartbeatTimer); ownerHeartbeatTimer = null; }
87
+ }
88
+ function _stopNonOwnerPoll() {
89
+ if (nonOwnerPollTimer) { clearInterval(nonOwnerPollTimer); nonOwnerPollTimer = null; }
90
+ }
91
+
71
92
  function getEventBuffer() {
72
93
  return wsServer ? wsServer.buffer : null;
73
94
  }
@@ -75,12 +96,19 @@ function getEventBuffer() {
75
96
  // from the persisted active profile (credentials.json) at boot, but in-process
76
97
  // switches may diverge from the persisted active until the next server restart.
77
98
  //
78
- // Profile selection precedence (v1.3.8 E.1):
79
- // 1. process.env.FEISHU_PLUGIN_PROFILEharness pointer
80
- // 2. credentials.json::activesingle-file persisted active
99
+ // Profile selection precedence (v1.3.9 A.2 — SSOT):
100
+ // 1. credentials.json::activesingle-file persisted active (canonical SSOT)
101
+ // 2. process.env.FEISHU_PLUGIN_PROFILEharness pointer (bootstrap-only, when
102
+ // credentials.json does not exist)
81
103
  // 3. 'default' — legacy zero-config path
82
- let currentProfile = process.env.FEISHU_PLUGIN_PROFILE
83
- || credentials.getActiveProfileName();
104
+ // Note: FEISHU_PLUGIN_PROFILE in the harness env is now a bootstrap pointer only.
105
+ // Once credentials.json exists, its `active` field is authoritative; cross-process
106
+ // sync propagates active-profile changes without a server restart.
107
+ let currentProfile = credentials.getActiveProfileName();
108
+ if (!credentials.readCanonical() && process.env.FEISHU_PLUGIN_PROFILE) {
109
+ // Bootstrap-only: legacy env users without canonical credentials file.
110
+ currentProfile = process.env.FEISHU_PLUGIN_PROFILE;
111
+ }
84
112
 
85
113
  function profileEnv(name) {
86
114
  return credentials.getActiveProfileEnv(name);
@@ -134,6 +162,157 @@ function loadUATFromEnv(client, env) {
134
162
  client._uatExpires = expires || client._decodeTokenExpiry(token);
135
163
  }
136
164
 
165
+ // --- Owner control loop (v1.3.9 A.1) ---
166
+
167
+ async function _claimAndStart() {
168
+ const claim = events.owner.tryClaim(FEISHU_HOME, { info: { role: 'ws_owner' } });
169
+ if (!claim.isOwner) {
170
+ // Become non-owner: poll lock health every 30s, attempt takeover when stale.
171
+ if (!nonOwnerPollTimer) {
172
+ nonOwnerPollTimer = setInterval(() => {
173
+ const info = events.owner.readOwnerInfo(FEISHU_HOME);
174
+ if (!info.exists || !info.alive) {
175
+ _claimAndStart().catch((e) => console.error(`[feishu-user-plugin] takeover attempt failed: ${e.message}`));
176
+ }
177
+ }, events.owner.TAKEOVER_POLL_INTERVAL_MS);
178
+ nonOwnerPollTimer.unref?.();
179
+ }
180
+ return;
181
+ }
182
+
183
+ ownerHandle = claim;
184
+ _stopNonOwnerPoll();
185
+ // Repair tail before WS starts pushing events
186
+ try { events.log.repairTail(EVENTS_LOG_PATH); } catch (_) {}
187
+
188
+ // Start WS with current active profile and its events list.
189
+ const profileName = currentProfile;
190
+ let activeEnv;
191
+ try { activeEnv = profileEnv(profileName); } catch (_) { return; }
192
+ if (!activeEnv.LARK_APP_ID || !activeEnv.LARK_APP_SECRET) return;
193
+
194
+ const eventsList = _getProfileEventsList(profileName);
195
+ wsServer = events.createWSServer({
196
+ appId: activeEnv.LARK_APP_ID,
197
+ appSecret: activeEnv.LARK_APP_SECRET,
198
+ registrations: eventsList,
199
+ logPath: EVENTS_LOG_PATH,
200
+ initialProfile: profileName,
201
+ });
202
+ wsServer.start().catch((e) => console.error(`[feishu-user-plugin] WS start error: ${e.message}`));
203
+
204
+ // Heartbeat + check active changes every 15s.
205
+ let lastCredMtime = _credMtime();
206
+ ownerHeartbeatTimer = setInterval(() => {
207
+ if (ownerHandle) ownerHandle.heartbeat();
208
+ const m = _credMtime();
209
+ if (m !== null && m !== lastCredMtime) {
210
+ lastCredMtime = m;
211
+ _maybeReconfigure().catch((e) => console.error(`[feishu-user-plugin] reconfigure failed: ${e.message}`));
212
+ }
213
+ // Defer-rotate check
214
+ try {
215
+ const snap = events.cursor.readSnapshot(FEISHU_HOME);
216
+ const SOFT_CAP = 10 * 1024 * 1024;
217
+ const HARD_CAP = 20 * 1024 * 1024;
218
+ const rot = events.log.maybeRotate(EVENTS_LOG_PATH, snap.cursor.offset, SOFT_CAP);
219
+ if (rot.rotated) {
220
+ events.cursor.resetCursorTo(FEISHU_HOME, 0);
221
+ } else if (snap.fileSize > HARD_CAP) {
222
+ events.log.forceRotate(EVENTS_LOG_PATH, snap.fileSize);
223
+ events.cursor.resetCursorTo(FEISHU_HOME, 0);
224
+ }
225
+ // Also clean up old .dropped files daily-ish (every heartbeat is cheap)
226
+ events.log.cleanupDropped(EVENTS_LOG_PATH, 7);
227
+ } catch (e) {
228
+ console.error(`[feishu-user-plugin] rotation check failed: ${e.message}`);
229
+ }
230
+ }, events.owner.HEARTBEAT_INTERVAL_MS);
231
+ ownerHeartbeatTimer.unref?.();
232
+
233
+ for (const cb of _ownerStartCallbacks) {
234
+ try { cb(); } catch (_) {}
235
+ }
236
+ }
237
+
238
+ function _credMtime() {
239
+ try {
240
+ const p = path.join(FEISHU_HOME, 'credentials.json');
241
+ return fs.statSync(p).mtimeMs;
242
+ } catch (_) { return null; }
243
+ }
244
+
245
+ // Cross-process active-profile sync (v1.3.9 A.2).
246
+ // Each tool call: stat credentials.json; if mtime changed AND active differs
247
+ // from in-memory currentProfile, do an in-process setActiveProfile().
248
+ // _credMtimeBaseline starts null — first call sets the baseline without syncing.
249
+ let _credMtimeBaseline = null;
250
+
251
+ function _syncActiveProfileFromDisk() {
252
+ const m = _credMtime();
253
+ if (m === null) return; // no credentials.json — nothing to sync
254
+ if (_credMtimeBaseline === null) { _credMtimeBaseline = m; return; }
255
+ if (m === _credMtimeBaseline) return; // unchanged
256
+ _credMtimeBaseline = m;
257
+ let newActive;
258
+ try { newActive = credentials.getActiveProfileName(); } catch (_) { return; }
259
+ if (newActive === currentProfile) return;
260
+ console.error(`[feishu-user-plugin] active profile changed on disk: ${currentProfile} → ${newActive}`);
261
+ // Use the same setActiveProfile flow that switch_profile uses, except don't
262
+ // re-write credentials.json (which it already reflects the change).
263
+ try {
264
+ credentials.getActiveProfileEnv(newActive); // validate profile exists
265
+ currentProfile = newActive;
266
+ userClient = null;
267
+ 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
+ require('./resolver').clearCache();
271
+ } catch (e) {
272
+ console.error(`[feishu-user-plugin] sync to "${newActive}" failed: ${e.message}; staying on "${currentProfile}"`);
273
+ }
274
+ }
275
+
276
+ async function _maybeReconfigure() {
277
+ if (!ownerHandle || !wsServer) return;
278
+ const newActive = credentials.getActiveProfileName();
279
+ const newEvents = _getProfileEventsList(newActive);
280
+ const status = wsServer.getStatus();
281
+ const sameProfile = status.wsProfile === newActive;
282
+ const sameEvents = JSON.stringify(status.subscribed_events) === JSON.stringify(newEvents);
283
+ if (sameProfile && sameEvents) return;
284
+
285
+ console.error(`[feishu-user-plugin] WS reconfigure: profile ${status.wsProfile}→${newActive}, events ${status.subscribed_events.length}→${newEvents.length}`);
286
+
287
+ // Tear down + rebuild because registrations are fixed at construction.
288
+ await wsServer.stop();
289
+ let activeEnv;
290
+ try { activeEnv = profileEnv(newActive); } catch (_) { return; }
291
+ if (!activeEnv.LARK_APP_ID || !activeEnv.LARK_APP_SECRET) return;
292
+ wsServer = events.createWSServer({
293
+ appId: activeEnv.LARK_APP_ID,
294
+ appSecret: activeEnv.LARK_APP_SECRET,
295
+ registrations: newEvents,
296
+ logPath: EVENTS_LOG_PATH,
297
+ initialProfile: newActive,
298
+ });
299
+ await wsServer.start();
300
+ }
301
+
302
+ function _getProfileEventsList(profileName) {
303
+ const canonical = credentials.readCanonical();
304
+ if (canonical && canonical.profiles[profileName]?.events) {
305
+ return canonical.profiles[profileName].events.slice();
306
+ }
307
+ // Bootstrap: env override
308
+ const env = process.env.FEISHU_PLUGIN_EXTRA_EVENTS;
309
+ if (env) {
310
+ const list = env.split(',').map(s => s.trim()).filter(Boolean);
311
+ if (list.length) return ['im.message.receive_v1', ...list];
312
+ }
313
+ return ['im.message.receive_v1'];
314
+ }
315
+
137
316
  // Resolver helper: turn document_id / app_token / wiki node / Feishu URL into
138
317
  // a native token. No-op for already-native inputs. See src/resolver.js.
139
318
  async function resolveDocId(input) {
@@ -158,11 +337,45 @@ function buildCtx() {
158
337
  currentProfile = n;
159
338
  userClient = null;
160
339
  officialClient = null;
340
+ // Clear resolver cache so wiki-node lookups don't carry over from the old profile.
341
+ require('./resolver').clearCache();
161
342
  // Persist the active-field flip when credentials.json exists so peer MCP
162
- // servers see the new active profile on next read. Tolerated when no file.
163
- try { credentials.setActiveProfile(n); } catch (_) {}
343
+ // servers see the new active profile on next read. The credentials module
344
+ // throws if credentials.json doesn't exist OR if `n` isn't in profiles[];
345
+ // the first is benign (legacy mode), the second is a real bug — log it
346
+ // either way at warn level instead of swallowing silently.
347
+ try { credentials.setActiveProfile(n); }
348
+ catch (e) {
349
+ const cred = credentials.readCanonical();
350
+ if (cred) {
351
+ 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
+ }
353
+ }
354
+ // Re-baseline mtime so _syncActiveProfileFromDisk doesn't immediately
355
+ // trigger a redundant sync on the next tool call after we just wrote.
356
+ _credMtimeBaseline = _credMtime();
164
357
  },
165
358
  resolveDocId,
359
+ getWsServer: () => wsServer,
360
+ requestClaim: async ({ force = false } = {}) => {
361
+ const claim = events.owner.tryClaim(FEISHU_HOME, { info: { role: 'ws_owner' }, force });
362
+ if (claim.isOwner) {
363
+ ownerHandle = claim;
364
+ await _claimAndStart();
365
+ return { ok: true, became_owner: true };
366
+ }
367
+ return { ok: false, reason: 'lock_active_no_force', owner_pid: claim.ownerInfo?.pid };
368
+ },
369
+ requestReconfigure: async () => {
370
+ const before = wsServer?.getStatus() || {};
371
+ await _maybeReconfigure();
372
+ const after = wsServer?.getStatus() || {};
373
+ return {
374
+ prev_subscriptions: before.subscribed_events || [],
375
+ next_subscriptions: after.subscribed_events || [],
376
+ no_change: JSON.stringify(before.subscribed_events) === JSON.stringify(after.subscribed_events) && before.wsProfile === after.wsProfile,
377
+ };
378
+ },
166
379
  };
167
380
  }
168
381
 
@@ -176,6 +389,9 @@ const server = new Server(
176
389
  server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
177
390
 
178
391
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
392
+ // Cross-process active-profile sync (v1.3.9 A.2): if another MCP process
393
+ // wrote a new `active` field to credentials.json, pick it up before dispatch.
394
+ _syncActiveProfileFromDisk();
179
395
  const { name, arguments: args } = request.params;
180
396
  const handler = HANDLERS[name];
181
397
  if (!handler) {
@@ -211,8 +427,18 @@ process.on('uncaughtException', (err) => {
211
427
  process.on('unhandledRejection', (reason) => {
212
428
  console.error('[feishu-user-plugin] Unhandled rejection:', reason);
213
429
  });
214
- process.on('SIGTERM', () => { try { wsServer?.stop(); } catch {} process.exit(0); });
215
- process.on('SIGINT', () => { try { wsServer?.stop(); } catch {} process.exit(0); });
430
+ process.on('SIGTERM', () => {
431
+ _stopHeartbeat(); _stopNonOwnerPoll();
432
+ try { wsServer?.stop(); } catch {}
433
+ try { ownerHandle?.release(); } catch {}
434
+ process.exit(0);
435
+ });
436
+ process.on('SIGINT', () => {
437
+ _stopHeartbeat(); _stopNonOwnerPoll();
438
+ try { wsServer?.stop(); } catch {}
439
+ try { ownerHandle?.release(); } catch {}
440
+ process.exit(0);
441
+ });
216
442
 
217
443
  // --- main ---
218
444
 
@@ -222,9 +448,11 @@ async function main() {
222
448
 
223
449
  // Startup diagnostics — use the resolved active-profile env so users on
224
450
  // credentials.json (where process.env may not have LARK_*) get accurate flags.
225
- // If FEISHU_PLUGIN_PROFILE was set, validate the name exists. If not, fail
226
- // loud silently falling back to "default" would mask a typo'd harness env.
227
- if (process.env.FEISHU_PLUGIN_PROFILE) {
451
+ // Bootstrap-only validation: when credentials.json doesn't exist and
452
+ // FEISHU_PLUGIN_PROFILE is set, validate the name against known legacy profiles.
453
+ // If credentials.json exists, FEISHU_PLUGIN_PROFILE is ignored — the file's
454
+ // `active` field is the SSOT and cross-process sync keeps it up to date.
455
+ if (!credentials.readCanonical() && process.env.FEISHU_PLUGIN_PROFILE) {
228
456
  const known = credentials.listProfileNames();
229
457
  if (!known.includes(currentProfile)) {
230
458
  console.error(`[feishu-user-plugin] FATAL: FEISHU_PLUGIN_PROFILE="${currentProfile}" not found. Known: ${known.join(', ')}.`);
@@ -275,24 +503,15 @@ async function main() {
275
503
  }
276
504
  }
277
505
 
278
- // --- Real-time events (v1.3.8 C.3) ---
279
- // Boot WS only when APP_ID/SECRET are valid. WS uses app credentials
280
- // (not UAT), so cookie-only setups don't get realtime.
506
+ // --- Real-time events (v1.3.9 — owner-arbitrated) ---
281
507
  if (hasApp) {
282
- try {
283
- const { createWSServer } = require('./events');
284
- wsServer = createWSServer({
285
- appId: activeEnv.LARK_APP_ID,
286
- appSecret: activeEnv.LARK_APP_SECRET,
287
- registrations: ['im.message.receive_v1'],
288
- });
289
- // Start asynchronously — don't block MCP serving on WS handshake.
290
- wsServer.start().catch((e) => {
291
- console.error(`[feishu-user-plugin] WS deferred start error: ${e.message}`);
292
- });
293
- } catch (e) {
294
- console.error(`[feishu-user-plugin] WS init failed: ${e.message}. Continuing without realtime.`);
295
- }
508
+ _claimAndStart().catch((e) => {
509
+ console.error(`[feishu-user-plugin] owner claim failed: ${e.message}; will retry every 30s`);
510
+ if (!nonOwnerPollTimer) {
511
+ nonOwnerPollTimer = setInterval(_claimAndStart, events.owner.TAKEOVER_POLL_INTERVAL_MS);
512
+ nonOwnerPollTimer.unref?.();
513
+ }
514
+ });
296
515
  } else {
297
516
  console.error('[feishu-user-plugin] WS not started — APP_ID/SECRET missing. Realtime events (get_new_events) will return empty.');
298
517
  }
package/src/setup.js CHANGED
@@ -10,9 +10,12 @@
10
10
  */
11
11
 
12
12
  const readline = require('readline');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
13
16
  const { findMcpConfig, writeNewConfig } = require('./config');
14
17
 
15
- // Parse CLI args: --app-id, --app-secret, --cookie, --client
18
+ // Parse CLI args: --app-id, --app-secret, --cookie, --client, --force, --profile
16
19
  function parseArgs() {
17
20
  const args = {};
18
21
  const argv = process.argv.slice(2);
@@ -21,7 +24,10 @@ function parseArgs() {
21
24
  else if (argv[i] === '--app-secret' && argv[i + 1]) args.appSecret = argv[++i];
22
25
  else if (argv[i] === '--cookie' && argv[i + 1]) args.cookie = argv[++i];
23
26
  else if (argv[i] === '--client' && argv[i + 1]) args.client = argv[++i];
24
- else if (argv[i] === '--pointer-only') args.pointerOnly = true;
27
+ else if (argv[i] === '--pointer-only') args.pointerOnly = true; // kept for backward compat; now implicit default
28
+ else if (argv[i] === '--force') args.force = true;
29
+ else if (argv[i] === '--profile' && argv[i + 1]) args.profile = argv[++i];
30
+ else if (argv[i] === '--activate') args.activate = true;
25
31
  }
26
32
  return args;
27
33
  }
@@ -151,34 +157,102 @@ async function main() {
151
157
  }
152
158
  if (!client) client = 'claude';
153
159
 
154
- // If credentials.json exists, recommend pointer-only the env block in
155
- // harness configs becomes redundant (and divergent on UAT refresh).
156
- const { readCanonical } = require('./auth/credentials');
157
- const hasCanonical = !!readCanonical();
158
- let pointerOnly = !!cliArgs.pointerOnly;
159
- if (hasCanonical && !pointerOnly && !nonInteractive) {
160
- console.log('\n--- Pointer-only mode ---');
161
- console.log('Detected ~/.feishu-user-plugin/credentials.json. You can write only');
162
- console.log('FEISHU_PLUGIN_PROFILE=default to the harness env (recommended for clean configs).');
163
- const ans = (await ask('Use pointer-only mode? (y/N): ')).trim().toLowerCase();
164
- pointerOnly = (ans === 'y' || ans === 'yes');
165
- }
160
+ // --- 4-state SSOT matrix (v1.3.9 A.3) ---
161
+ // Determines how credentials.json is created/updated based on current state.
162
+ //
163
+ // State 1 (fresh): credentials.json absent, no harness LARK_* env → create fresh
164
+ // State 2 (auto-migrate): credentials.json absent, harness LARK_* env exists → migrate
165
+ // State 3 (preserve): credentials.json present, no --app-id touch nothing in file
166
+ // State 4 (update): credentials.json present, --app-id given → update/add profile
167
+ const credentials = require('./auth/credentials');
168
+ const credsPath = path.join(os.homedir(), '.feishu-user-plugin', 'credentials.json');
169
+ const credsExist = !!credentials.readCanonical();
170
+ const targetProfile = cliArgs.profile || 'default';
171
+ const harnessHasLark = !!(existingEnv.LARK_APP_ID || existingEnv.LARK_COOKIE || existingEnv.LARK_USER_ACCESS_TOKEN);
166
172
 
167
- // Write config
168
- console.log('\n--- Writing Config ---');
173
+ let mode;
174
+ if (!credsExist && !harnessHasLark) mode = 'fresh';
175
+ else if (!credsExist && harnessHasLark) mode = 'auto-migrate';
176
+ else if (credsExist && !cliArgs.appId) mode = 'preserve';
177
+ else mode = 'update';
178
+ console.log(`\nSetup mode: ${mode}`);
169
179
 
170
- const env = {
171
- LARK_COOKIE: cookie,
172
- LARK_APP_ID: appId,
173
- LARK_APP_SECRET: appSecret,
174
- LARK_USER_ACCESS_TOKEN: hasUAT ? existingUAT : 'SETUP_NEEDED',
175
- LARK_USER_REFRESH_TOKEN: hasUAT ? (existingRT || '') : '',
176
- };
180
+ if (mode === 'fresh' || mode === 'update') {
181
+ // Gather profile values — only include keys that have real values.
182
+ const profileValues = {};
183
+ if (appId) profileValues.LARK_APP_ID = appId;
184
+ if (appSecret) profileValues.LARK_APP_SECRET = appSecret;
185
+ if (cookie && cookie !== 'SETUP_NEEDED') profileValues.LARK_COOKIE = cookie;
186
+ else if (existingEnv.LARK_COOKIE && existingEnv.LARK_COOKIE !== 'SETUP_NEEDED') profileValues.LARK_COOKIE = existingEnv.LARK_COOKIE;
187
+ if (existingEnv.LARK_USER_ACCESS_TOKEN && existingEnv.LARK_USER_ACCESS_TOKEN !== 'SETUP_NEEDED') profileValues.LARK_USER_ACCESS_TOKEN = existingEnv.LARK_USER_ACCESS_TOKEN;
188
+ if (existingEnv.LARK_USER_REFRESH_TOKEN) profileValues.LARK_USER_REFRESH_TOKEN = existingEnv.LARK_USER_REFRESH_TOKEN;
177
189
 
178
- const result = writeNewConfig(env, undefined, undefined, client, { pointerOnly });
190
+ if (mode === 'fresh') {
191
+ fs.mkdirSync(path.dirname(credsPath), { recursive: true, mode: 0o700 });
192
+ const data = { version: 1, active: targetProfile, profiles: { [targetProfile]: profileValues }, profileHints: {} };
193
+ fs.writeFileSync(credsPath, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
194
+ console.log(`Created ${credsPath} with profile "${targetProfile}"`);
195
+ } else {
196
+ // mode === 'update'
197
+ const canonical = credentials.readCanonical();
198
+ const profileExists = !!(canonical && canonical.profiles[targetProfile]);
199
+ if (profileExists && targetProfile === 'default' && !cliArgs.force && !cliArgs.profile) {
200
+ console.error(`Profile "default" already exists. Pass --force to overwrite, or --profile <name> to create a new profile.`);
201
+ rl.close();
202
+ process.exit(1);
203
+ }
204
+ if (profileExists && cliArgs.profile && !cliArgs.force) {
205
+ console.error(`Profile "${targetProfile}" already exists. Pass --force to overwrite, or pick a different --profile name.`);
206
+ rl.close();
207
+ process.exit(1);
208
+ }
209
+ canonical.profiles[targetProfile] = { ...(canonical.profiles[targetProfile] || {}), ...profileValues };
210
+ // v1.3.9 fix: only flip credentials.json::active when --activate is given.
211
+ // Without --activate, adding/updating a non-active profile leaves the
212
+ // current active alone (least-surprise: "I added work2, default is still
213
+ // active, I'll switch when I want via MCP switch_profile").
214
+ if (cliArgs.activate || (cliArgs.force && targetProfile === canonical.active)) {
215
+ canonical.active = targetProfile;
216
+ }
217
+ fs.writeFileSync(credsPath, JSON.stringify(canonical, null, 2) + '\n', { mode: 0o600 });
218
+ console.log(`Updated profile "${targetProfile}" in ${credsPath}`);
219
+ if (cliArgs.activate) console.log(` → active profile flipped to "${targetProfile}"`);
220
+ else if (canonical.active !== targetProfile) {
221
+ console.log(` → active profile unchanged ("${canonical.active}"). Pass --activate to flip, or use switch_profile MCP tool at runtime.`);
222
+ }
223
+ if (cliArgs.force) console.warn(` warning: overwrote existing profile credentials with --force`);
224
+ }
225
+ } else if (mode === 'auto-migrate') {
226
+ // Run migrate to consolidate harness env → credentials.json, then optionally
227
+ // override the default profile with any explicitly provided --app-id.
228
+ const result = credentials.migrate({ dryRun: false });
229
+ if (!result.ok) {
230
+ console.error('Auto-migrate failed; aborting setup.');
231
+ rl.close();
232
+ process.exit(1);
233
+ }
234
+ if (cliArgs.appId) {
235
+ credentials.persistProfileUpdate('default', { LARK_APP_ID: appId, LARK_APP_SECRET: appSecret });
236
+ console.log('Updated default profile with new app credentials.');
237
+ }
238
+ }
239
+ // mode === 'preserve': credentials.json is unchanged; we only update the harness pointer.
240
+
241
+ // --- Write harness config ---
242
+ // Always write pointer-only env to harness configs (v1.3.9 SSOT).
243
+ // The harness env block only needs FEISHU_PLUGIN_PROFILE; all real creds
244
+ // live in credentials.json.
245
+ console.log('\n--- Writing Config ---');
246
+ // v1.3.9 fix: harness env pointer should reflect what credentials.json::active
247
+ // will end up as, not blindly the targetProfile (which would mislead users
248
+ // who added a non-active profile via --profile alt without --activate).
249
+ const finalCanonical = credentials.readCanonical();
250
+ const harnessActive = finalCanonical?.active || targetProfile;
251
+ const pointerEnv = { FEISHU_PLUGIN_PROFILE: harnessActive };
252
+ const result = writeNewConfig(pointerEnv, undefined, undefined, client, { pointerOnly: true });
179
253
  if (result.configPath) console.log(`Written to ${result.configPath} (Claude Code)`);
180
254
  if (result.codexConfigPath) console.log(`Written to ${result.codexConfigPath} (Codex)`);
181
- if (pointerOnly) console.log('Mode: pointer-only (env block contains only FEISHU_PLUGIN_PROFILE)');
255
+ console.log(`Mode: pointer-only (env block contains only FEISHU_PLUGIN_PROFILE=${targetProfile})`);
182
256
 
183
257
  // Summary
184
258
  console.log('\n' + '='.repeat(60));
package/src/test-all.js CHANGED
@@ -266,14 +266,6 @@ async function main() {
266
266
  // 31. create_folder (skip)
267
267
  log('create_folder', 'SKIP', 'skipped to avoid creating unnecessary folders');
268
268
 
269
- // 32. find_user
270
- try {
271
- const res = await officialClient.findUserByIdentity({ emails: 'test@test.com' });
272
- log('find_user', 'PASS', `returned ${(res.userList || []).length} users (expected 0 for test email)`);
273
- } catch (e) {
274
- log('find_user', 'FAIL', e.message);
275
- }
276
-
277
269
  // ========== UAT Tests ==========
278
270
 
279
271
  if (officialClient.hasUAT) {
@@ -321,4 +313,15 @@ async function main() {
321
313
  }
322
314
  }
323
315
 
324
- main().catch(console.error);
316
+ main().catch(console.error).finally(() => {
317
+ // Fixture-based unit test — runs regardless of credential availability
318
+ require('./test-read-doc-markdown').run();
319
+ require('./test-switch-profile').run().catch(e => {
320
+ console.error('switch-profile-e2e: FAIL', e);
321
+ process.exitCode = 1;
322
+ });
323
+ require('./test-events-lockfile').run();
324
+ require('./test-events-log').run();
325
+ require('./test-events-cursor').run();
326
+ require('./test-events-owner').run();
327
+ });