feishu-user-plugin 1.3.9 → 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 +68 -0
- package/PRIVACY.md +105 -0
- package/README.en.md +610 -0
- package/README.md +309 -529
- package/package.json +7 -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/src/server.js
CHANGED
|
@@ -80,6 +80,11 @@ let ownerHeartbeatTimer = null;
|
|
|
80
80
|
let nonOwnerPollTimer = null;
|
|
81
81
|
let _ownerStartCallbacks = [];
|
|
82
82
|
|
|
83
|
+
// Lark Desktop reactor state (v1.3.11 §A) — owned by the heartbeat callback.
|
|
84
|
+
let _lastHashMtimes = {};
|
|
85
|
+
let _lastSwitchAt = 0;
|
|
86
|
+
const _seenUnboundHashes = new Set();
|
|
87
|
+
|
|
83
88
|
function _onBecomeOwner(cb) { _ownerStartCallbacks.push(cb); }
|
|
84
89
|
|
|
85
90
|
function _stopHeartbeat() {
|
|
@@ -203,6 +208,9 @@ async function _claimAndStart() {
|
|
|
203
208
|
|
|
204
209
|
// Heartbeat + check active changes every 15s.
|
|
205
210
|
let lastCredMtime = _credMtime();
|
|
211
|
+
// Bootstrap baseline so the very first heartbeat doesn't trigger a switch.
|
|
212
|
+
_lastHashMtimes = require('./auth/lark-desktop').listAccountHashes()
|
|
213
|
+
.reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
|
|
206
214
|
ownerHeartbeatTimer = setInterval(() => {
|
|
207
215
|
if (ownerHandle) ownerHandle.heartbeat();
|
|
208
216
|
const m = _credMtime();
|
|
@@ -210,6 +218,12 @@ async function _claimAndStart() {
|
|
|
210
218
|
lastCredMtime = m;
|
|
211
219
|
_maybeReconfigure().catch((e) => console.error(`[feishu-user-plugin] reconfigure failed: ${e.message}`));
|
|
212
220
|
}
|
|
221
|
+
// Lark Desktop reactor (v1.3.11 §A)
|
|
222
|
+
try {
|
|
223
|
+
_runLarkDesktopReactor();
|
|
224
|
+
} catch (e) {
|
|
225
|
+
console.error(`[feishu-user-plugin] Lark reactor error: ${e.message}`);
|
|
226
|
+
}
|
|
213
227
|
// Defer-rotate check
|
|
214
228
|
try {
|
|
215
229
|
const snap = events.cursor.readSnapshot(FEISHU_HOME);
|
|
@@ -242,6 +256,34 @@ function _credMtime() {
|
|
|
242
256
|
} catch (_) { return null; }
|
|
243
257
|
}
|
|
244
258
|
|
|
259
|
+
// Lark Desktop reactor (v1.3.11 §A).
|
|
260
|
+
// Called from the owner heartbeat. When the most-recently-active hash differs
|
|
261
|
+
// from the active profile's bound hash AND its mtime advanced since the last
|
|
262
|
+
// snapshot, flip credentials.json::active to the matching profile (the existing
|
|
263
|
+
// _credMtime delta on the next tick triggers _maybeReconfigure which restarts
|
|
264
|
+
// the WS client with the new profile's events list).
|
|
265
|
+
function _runLarkDesktopReactor() {
|
|
266
|
+
const ld = require('./auth/lark-desktop');
|
|
267
|
+
const out = ld.detectSwitch({
|
|
268
|
+
prevSnapshot: _lastHashMtimes,
|
|
269
|
+
lastSwitchAt: _lastSwitchAt,
|
|
270
|
+
seenUnboundHashes: _seenUnboundHashes,
|
|
271
|
+
});
|
|
272
|
+
if (out.switchTo) {
|
|
273
|
+
_lastSwitchAt = Date.now();
|
|
274
|
+
console.error(
|
|
275
|
+
`[feishu-user-plugin] Lark Desktop account changed; switching profile to ` +
|
|
276
|
+
`"${out.switchTo.profile}" (hash ${out.switchTo.hash})`
|
|
277
|
+
);
|
|
278
|
+
try { credentials.setActiveProfile(out.switchTo.profile); }
|
|
279
|
+
catch (e) { console.error(`[feishu-user-plugin] setActiveProfile failed: ${e.message}`); }
|
|
280
|
+
}
|
|
281
|
+
// Refresh snapshot regardless of switch outcome — keeps debounce + advance
|
|
282
|
+
// detection consistent on subsequent ticks.
|
|
283
|
+
_lastHashMtimes = ld.listAccountHashes()
|
|
284
|
+
.reduce((acc, h) => { acc[h.hash] = h.mtimeMs; return acc; }, {});
|
|
285
|
+
}
|
|
286
|
+
|
|
245
287
|
// Cross-process active-profile sync (v1.3.9 A.2).
|
|
246
288
|
// Each tool call: stat credentials.json; if mtime changed AND active differs
|
|
247
289
|
// from in-memory currentProfile, do an in-process setActiveProfile().
|
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
|
|
@@ -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
|
+
}
|