feishu-user-plugin 1.3.10 → 1.3.11
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 +1 -1
- package/.cursor-plugin/plugin.json +27 -0
- package/.mcpb/manifest.json +91 -0
- package/CHANGELOG.md +18 -0
- package/PRIVACY.md +105 -0
- package/README.md +20 -0
- package/package.json +4 -2
- package/scripts/build-mcpb.js +119 -0
- package/scripts/check-mcp-registry-version.js +43 -0
- package/scripts/check-mcpb-version.js +33 -0
- package/scripts/check-version.js +5 -0
- package/scripts/sync-team-skills.sh +72 -57
- package/skills/feishu-user-plugin/SKILL.md +1 -1
- package/skills/feishu-user-plugin/references/CLAUDE.md +1 -0
- package/src/auth/credentials.js +49 -0
- package/src/auth/lark-desktop.js +135 -0
- package/src/server.js +42 -0
- package/src/setup.js +44 -0
- package/src/test-lark-desktop.js +300 -0
- package/scripts/generate-og-image.js +0 -39
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
// src/test-lark-desktop.js — unit tests for src/auth/lark-desktop.js
|
|
2
|
+
// + src/auth/credentials.js larkHash bindings.
|
|
3
|
+
// Plain assert + fixture-based; no external deps.
|
|
4
|
+
//
|
|
5
|
+
// Run: `node src/test-lark-desktop.js`
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const assert = require('assert');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const os = require('os');
|
|
13
|
+
|
|
14
|
+
const FIX_ROOT = path.join(os.tmpdir(), 'feishu-test-sdk-storage-' + process.pid + '-' + Date.now());
|
|
15
|
+
|
|
16
|
+
function makeFixture(hashes) {
|
|
17
|
+
fs.mkdirSync(FIX_ROOT, { recursive: true });
|
|
18
|
+
for (const [hash, mtimeOffset] of hashes) {
|
|
19
|
+
const dir = path.join(FIX_ROOT, hash);
|
|
20
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
21
|
+
const dbPath = path.join(dir, 'cookie_store.db');
|
|
22
|
+
fs.writeFileSync(dbPath, 'fake');
|
|
23
|
+
const t = (Date.now() / 1000) + mtimeOffset;
|
|
24
|
+
fs.utimesSync(dbPath, t, t);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function cleanupFixture() {
|
|
29
|
+
fs.rmSync(FIX_ROOT, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ld = require('./auth/lark-desktop');
|
|
33
|
+
|
|
34
|
+
// --- Task 1: read-only basics ---
|
|
35
|
+
|
|
36
|
+
function testListAccountHashes() {
|
|
37
|
+
cleanupFixture();
|
|
38
|
+
makeFixture([
|
|
39
|
+
['aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', -100],
|
|
40
|
+
['bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 0],
|
|
41
|
+
['cccccccccccccccccccccccccccccccc', -50],
|
|
42
|
+
['notahash', 0], // should be filtered out (not 32-hex)
|
|
43
|
+
['DEADBEEFDEADBEEFDEADBEEFDEADBEEF', 0], // uppercase — also filtered (we accept lowercase only)
|
|
44
|
+
]);
|
|
45
|
+
// uppercase entry has no cookie_store.db tweaking — make sure dir exists at minimum
|
|
46
|
+
// (already created; just confirming file presence)
|
|
47
|
+
const list = ld.listAccountHashes({ dir: FIX_ROOT });
|
|
48
|
+
assert.strictEqual(list.length, 3, `filters non-hex names; got ${list.length}: ${list.map(h=>h.hash).join(',')}`);
|
|
49
|
+
assert.strictEqual(list[0].hash, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', 'sorted by mtime desc');
|
|
50
|
+
assert.strictEqual(list[2].hash, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
|
51
|
+
assert.ok(typeof list[0].mtimeMs === 'number');
|
|
52
|
+
assert.ok(list[0].dir.endsWith('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'));
|
|
53
|
+
cleanupFixture();
|
|
54
|
+
console.log('PASS: listAccountHashes');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function testListAccountHashesEmpty() {
|
|
58
|
+
cleanupFixture();
|
|
59
|
+
fs.mkdirSync(FIX_ROOT, { recursive: true });
|
|
60
|
+
// No hash dirs — should return []
|
|
61
|
+
assert.deepStrictEqual(ld.listAccountHashes({ dir: FIX_ROOT }), []);
|
|
62
|
+
cleanupFixture();
|
|
63
|
+
// Non-existent dir
|
|
64
|
+
assert.deepStrictEqual(ld.listAccountHashes({ dir: '/nonexistent-' + Date.now() }), []);
|
|
65
|
+
console.log('PASS: listAccountHashes empty / missing');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function testListAccountHashesIgnoresMissingDb() {
|
|
69
|
+
cleanupFixture();
|
|
70
|
+
fs.mkdirSync(path.join(FIX_ROOT, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), { recursive: true });
|
|
71
|
+
// No cookie_store.db file inside — entry should be skipped (we treat
|
|
72
|
+
// "no DB → never logged in / cleared" so it can't represent an active account)
|
|
73
|
+
const list = ld.listAccountHashes({ dir: FIX_ROOT });
|
|
74
|
+
assert.strictEqual(list.length, 0);
|
|
75
|
+
cleanupFixture();
|
|
76
|
+
console.log('PASS: listAccountHashes ignores hash dirs without cookie_store.db');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function testMostRecentHash() {
|
|
80
|
+
cleanupFixture();
|
|
81
|
+
makeFixture([
|
|
82
|
+
['1111111111111111111111111111aaaa', -200],
|
|
83
|
+
['2222222222222222222222222222bbbb', 0],
|
|
84
|
+
]);
|
|
85
|
+
const top = ld.mostRecentHash({ dir: FIX_ROOT });
|
|
86
|
+
assert.strictEqual(top.hash, '2222222222222222222222222222bbbb');
|
|
87
|
+
assert.strictEqual(ld.mostRecentHash({ dir: '/nonexistent-' + Date.now() }), null);
|
|
88
|
+
cleanupFixture();
|
|
89
|
+
console.log('PASS: mostRecentHash');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function testGetSdkStorageDirSafety() {
|
|
93
|
+
const dir = ld.getSdkStorageDir();
|
|
94
|
+
if (process.platform === 'darwin') {
|
|
95
|
+
assert.ok(dir === null || typeof dir === 'string');
|
|
96
|
+
} else {
|
|
97
|
+
assert.strictEqual(dir, null);
|
|
98
|
+
}
|
|
99
|
+
console.log('PASS: getSdkStorageDir platform safety');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- Task 2: profile hash bindings on credentials.js ---
|
|
103
|
+
|
|
104
|
+
function testProfileHashBindings() {
|
|
105
|
+
const sandbox = path.join(os.tmpdir(), 'feishu-test-creds-' + process.pid + '-' + Date.now());
|
|
106
|
+
fs.mkdirSync(path.join(sandbox, '.feishu-user-plugin'), { recursive: true, mode: 0o700 });
|
|
107
|
+
const credPath = path.join(sandbox, '.feishu-user-plugin', 'credentials.json');
|
|
108
|
+
|
|
109
|
+
const baseFile = {
|
|
110
|
+
version: 1,
|
|
111
|
+
active: 'default',
|
|
112
|
+
profiles: {
|
|
113
|
+
default: { LARK_APP_ID: 'cli_aaa' },
|
|
114
|
+
work: { LARK_APP_ID: 'cli_bbb' },
|
|
115
|
+
},
|
|
116
|
+
profileHints: {},
|
|
117
|
+
};
|
|
118
|
+
fs.writeFileSync(credPath, JSON.stringify(baseFile, null, 2));
|
|
119
|
+
|
|
120
|
+
const origHome = process.env.HOME;
|
|
121
|
+
process.env.HOME = sandbox;
|
|
122
|
+
|
|
123
|
+
// Force re-require so any cached internal state in credentials.js is fresh.
|
|
124
|
+
// (credentials.js doesn't actually cache — it re-reads on every call — but
|
|
125
|
+
// belt-and-suspenders.)
|
|
126
|
+
delete require.cache[require.resolve('./auth/credentials')];
|
|
127
|
+
const credentials = require('./auth/credentials');
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
// Initially unbound
|
|
131
|
+
assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), null);
|
|
132
|
+
assert.strictEqual(credentials.getProfileLarkHash('default'), null);
|
|
133
|
+
assert.strictEqual(credentials.getProfileLarkHash('work'), null);
|
|
134
|
+
|
|
135
|
+
// Bind default
|
|
136
|
+
credentials.setProfileLarkHash('default', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
|
137
|
+
assert.strictEqual(credentials.getProfileLarkHash('default'), 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
|
138
|
+
assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'default');
|
|
139
|
+
|
|
140
|
+
// Bind work
|
|
141
|
+
credentials.setProfileLarkHash('work', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
|
142
|
+
assert.strictEqual(credentials.findProfileByHash('bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'), 'work');
|
|
143
|
+
// default still bound
|
|
144
|
+
assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'default');
|
|
145
|
+
|
|
146
|
+
// Validation: bad hex
|
|
147
|
+
assert.throws(() => credentials.setProfileLarkHash('default', 'not-hex'),
|
|
148
|
+
/must be 32-char hex/);
|
|
149
|
+
// Validation: missing profile
|
|
150
|
+
assert.throws(() => credentials.setProfileLarkHash('nope', 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'),
|
|
151
|
+
/not found/);
|
|
152
|
+
|
|
153
|
+
// findProfileByHash with bad input → null
|
|
154
|
+
assert.strictEqual(credentials.findProfileByHash('not-hex'), null);
|
|
155
|
+
assert.strictEqual(credentials.findProfileByHash(null), null);
|
|
156
|
+
assert.strictEqual(credentials.findProfileByHash(undefined), null);
|
|
157
|
+
|
|
158
|
+
// Clear by passing null
|
|
159
|
+
credentials.setProfileLarkHash('default', null);
|
|
160
|
+
assert.strictEqual(credentials.getProfileLarkHash('default'), null);
|
|
161
|
+
assert.strictEqual(credentials.findProfileByHash('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), null);
|
|
162
|
+
} finally {
|
|
163
|
+
process.env.HOME = origHome;
|
|
164
|
+
fs.rmSync(sandbox, { recursive: true, force: true });
|
|
165
|
+
delete require.cache[require.resolve('./auth/credentials')];
|
|
166
|
+
}
|
|
167
|
+
console.log('PASS: profile hash bindings');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Task 4: detectSwitch logic (pure) ---
|
|
171
|
+
|
|
172
|
+
function testDetectSwitchDebounce() {
|
|
173
|
+
const result = ld.detectSwitch({
|
|
174
|
+
prevSnapshot: {},
|
|
175
|
+
lastSwitchAt: Date.now() - 1000, // 1s ago, < 5s debounce
|
|
176
|
+
seenUnboundHashes: new Set(),
|
|
177
|
+
listFn: () => [{ hash: 'a'.repeat(32), mtimeMs: Date.now(), dir: '/x' }],
|
|
178
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => 'default' },
|
|
179
|
+
});
|
|
180
|
+
assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
|
|
181
|
+
console.log('PASS: detectSwitch debounce');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function testDetectSwitchAlreadyOnMostRecent() {
|
|
185
|
+
const HASH = 'a'.repeat(32);
|
|
186
|
+
const result = ld.detectSwitch({
|
|
187
|
+
prevSnapshot: {},
|
|
188
|
+
lastSwitchAt: 0,
|
|
189
|
+
seenUnboundHashes: new Set(),
|
|
190
|
+
listFn: () => [{ hash: HASH, mtimeMs: Date.now(), dir: '/x' }],
|
|
191
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => HASH, findProfileByHash: () => 'default' },
|
|
192
|
+
});
|
|
193
|
+
assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
|
|
194
|
+
console.log('PASS: detectSwitch already on most-recent');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function testDetectSwitchNoMtimeAdvance() {
|
|
198
|
+
const HASH = 'a'.repeat(32);
|
|
199
|
+
const fixedMtime = Date.now() - 10_000;
|
|
200
|
+
const result = ld.detectSwitch({
|
|
201
|
+
prevSnapshot: { [HASH]: fixedMtime },
|
|
202
|
+
lastSwitchAt: 0,
|
|
203
|
+
seenUnboundHashes: new Set(),
|
|
204
|
+
listFn: () => [{ hash: HASH, mtimeMs: fixedMtime, dir: '/x' }],
|
|
205
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => 'b'.repeat(32), findProfileByHash: () => 'work' },
|
|
206
|
+
});
|
|
207
|
+
assert.deepStrictEqual(result, { switchTo: null, isUnbound: false });
|
|
208
|
+
console.log('PASS: detectSwitch no mtime advance');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function testDetectSwitchValid() {
|
|
212
|
+
const HASH = 'a'.repeat(32);
|
|
213
|
+
const result = ld.detectSwitch({
|
|
214
|
+
prevSnapshot: { [HASH]: 1000 },
|
|
215
|
+
lastSwitchAt: 0,
|
|
216
|
+
seenUnboundHashes: new Set(),
|
|
217
|
+
listFn: () => [{ hash: HASH, mtimeMs: 5000, dir: '/x' }],
|
|
218
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => 'b'.repeat(32), findProfileByHash: () => 'work' },
|
|
219
|
+
});
|
|
220
|
+
assert.deepStrictEqual(result, { switchTo: { hash: HASH, profile: 'work' }, isUnbound: false });
|
|
221
|
+
console.log('PASS: detectSwitch valid switch');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function testDetectSwitchUnboundEmitsOnce() {
|
|
225
|
+
const HASH = 'a'.repeat(32);
|
|
226
|
+
const seen = new Set();
|
|
227
|
+
const logs = [];
|
|
228
|
+
const log = (msg) => logs.push(msg);
|
|
229
|
+
const args = {
|
|
230
|
+
prevSnapshot: { [HASH]: 1000 },
|
|
231
|
+
lastSwitchAt: 0,
|
|
232
|
+
seenUnboundHashes: seen,
|
|
233
|
+
listFn: () => [{ hash: HASH, mtimeMs: Date.now(), dir: '/x' }],
|
|
234
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
|
|
235
|
+
log,
|
|
236
|
+
};
|
|
237
|
+
let r = ld.detectSwitch(args);
|
|
238
|
+
assert.deepStrictEqual(r, { switchTo: null, isUnbound: true, hash: HASH });
|
|
239
|
+
assert.strictEqual(logs.length, 1, 'first call emits hint');
|
|
240
|
+
assert.match(logs[0], /not bound to any MCP profile/);
|
|
241
|
+
assert.match(logs[0], new RegExp(`--bind-hash ${HASH}`));
|
|
242
|
+
r = ld.detectSwitch(args);
|
|
243
|
+
assert.strictEqual(logs.length, 1, 'second call deduplicated');
|
|
244
|
+
console.log('PASS: detectSwitch unbound emits hint once per session');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function testDetectSwitchUnboundStaleNoHint() {
|
|
248
|
+
// mtime older than UNBOUND_FRESH_WINDOW_MS → no hint emitted
|
|
249
|
+
const HASH = 'a'.repeat(32);
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
const logs = [];
|
|
252
|
+
const r = ld.detectSwitch({
|
|
253
|
+
prevSnapshot: { [HASH]: 1000 },
|
|
254
|
+
lastSwitchAt: 0,
|
|
255
|
+
seenUnboundHashes: seen,
|
|
256
|
+
listFn: () => [{ hash: HASH, mtimeMs: Date.now() - 120_000, dir: '/x' }], // 2 min ago
|
|
257
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
|
|
258
|
+
log: (msg) => logs.push(msg),
|
|
259
|
+
});
|
|
260
|
+
// Stale unbound hash: still reports isUnbound=true but doesn't add to seen / doesn't log
|
|
261
|
+
assert.strictEqual(r.isUnbound, true);
|
|
262
|
+
assert.strictEqual(logs.length, 0);
|
|
263
|
+
assert.strictEqual(seen.size, 0);
|
|
264
|
+
console.log('PASS: detectSwitch unbound stale → no hint');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function testDetectSwitchEmptyList() {
|
|
268
|
+
const r = ld.detectSwitch({
|
|
269
|
+
prevSnapshot: {},
|
|
270
|
+
lastSwitchAt: 0,
|
|
271
|
+
seenUnboundHashes: new Set(),
|
|
272
|
+
listFn: () => [],
|
|
273
|
+
credsApi: { getActiveProfileName: () => 'default', getProfileLarkHash: () => null, findProfileByHash: () => null },
|
|
274
|
+
});
|
|
275
|
+
assert.deepStrictEqual(r, { switchTo: null, isUnbound: false });
|
|
276
|
+
console.log('PASS: detectSwitch empty list');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- Run all ---
|
|
280
|
+
|
|
281
|
+
function run() {
|
|
282
|
+
testListAccountHashes();
|
|
283
|
+
testListAccountHashesEmpty();
|
|
284
|
+
testListAccountHashesIgnoresMissingDb();
|
|
285
|
+
testMostRecentHash();
|
|
286
|
+
testGetSdkStorageDirSafety();
|
|
287
|
+
testProfileHashBindings();
|
|
288
|
+
testDetectSwitchDebounce();
|
|
289
|
+
testDetectSwitchAlreadyOnMostRecent();
|
|
290
|
+
testDetectSwitchNoMtimeAdvance();
|
|
291
|
+
testDetectSwitchValid();
|
|
292
|
+
testDetectSwitchUnboundEmitsOnce();
|
|
293
|
+
testDetectSwitchUnboundStaleNoHint();
|
|
294
|
+
testDetectSwitchEmptyList();
|
|
295
|
+
console.log('\nAll lark-desktop tests passed.');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (require.main === module) {
|
|
299
|
+
run();
|
|
300
|
+
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Render docs/og.svg → docs/og.png at 1200x630.
|
|
3
|
-
//
|
|
4
|
-
// Idempotent. Run `node scripts/generate-og-image.js` after editing
|
|
5
|
-
// docs/og.svg. Commit both the SVG (source) and the PNG (asset used
|
|
6
|
-
// by social-media unfurls and `<meta property="og:image">`).
|
|
7
|
-
//
|
|
8
|
-
// Why PNG: Twitter / WeChat / 飞书 unfurls don't render SVG `og:image`.
|
|
9
|
-
|
|
10
|
-
'use strict';
|
|
11
|
-
|
|
12
|
-
const fs = require('fs');
|
|
13
|
-
const path = require('path');
|
|
14
|
-
const { Resvg } = require('@resvg/resvg-js');
|
|
15
|
-
|
|
16
|
-
const svgPath = path.join(__dirname, '..', 'docs', 'og.svg');
|
|
17
|
-
const pngPath = path.join(__dirname, '..', 'docs', 'og.png');
|
|
18
|
-
|
|
19
|
-
const svg = fs.readFileSync(svgPath, 'utf8');
|
|
20
|
-
|
|
21
|
-
const resvg = new Resvg(svg, {
|
|
22
|
-
fitTo: { mode: 'width', value: 1200 },
|
|
23
|
-
// Try to use system fonts so Chinese characters render. resvg-js loads
|
|
24
|
-
// fonts from `font.fontDirs` and `font.defaultFontFamily`. On macOS
|
|
25
|
-
// /System/Library/Fonts has PingFang SC; on Linux CI, jekyll-seo-tag
|
|
26
|
-
// doesn't run this script anyway — only humans do, locally.
|
|
27
|
-
font: {
|
|
28
|
-
fontDirs: ['/System/Library/Fonts', '/Library/Fonts', '/usr/share/fonts'],
|
|
29
|
-
loadSystemFonts: true,
|
|
30
|
-
defaultFontFamily: 'PingFang SC',
|
|
31
|
-
},
|
|
32
|
-
background: '#0d1117',
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
const pngBuffer = resvg.render().asPng();
|
|
36
|
-
fs.writeFileSync(pngPath, pngBuffer);
|
|
37
|
-
|
|
38
|
-
const sizeKb = (pngBuffer.length / 1024).toFixed(1);
|
|
39
|
-
console.log(`OK: wrote ${pngPath} (${pngBuffer.length} bytes / ${sizeKb} KiB)`);
|