@icoretech/warden-mcp 0.1.0

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.
@@ -0,0 +1,1225 @@
1
+ // src/sdk/keychainSdk.ts
2
+ import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { basename, join } from 'node:path';
5
+ import { BwCliError } from '../bw/bwCli.js';
6
+ import { buildBwGenerateArgs } from './generateArgs.js';
7
+ import { applyItemPatch } from './patch.js';
8
+ import { redactItem } from './redact.js';
9
+ import { generateUsername } from './usernameGenerator.js';
10
+ const ITEM_TYPE = {
11
+ login: 1,
12
+ note: 2,
13
+ card: 3,
14
+ identity: 4,
15
+ };
16
+ const URI_MATCH = {
17
+ domain: 0,
18
+ host: 1,
19
+ startsWith: 2,
20
+ exact: 3,
21
+ regex: 4,
22
+ never: 5,
23
+ };
24
+ const URI_MATCH_REVERSE = {
25
+ 0: 'domain',
26
+ 1: 'host',
27
+ 2: 'startsWith',
28
+ 3: 'exact',
29
+ 4: 'regex',
30
+ 5: 'never',
31
+ };
32
+ function deepClone(obj) {
33
+ return JSON.parse(JSON.stringify(obj));
34
+ }
35
+ function encodeJsonForBw(value) {
36
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64');
37
+ }
38
+ function normalizeFields(fields) {
39
+ if (!fields)
40
+ return undefined;
41
+ return fields.map((f) => ({
42
+ name: f.name,
43
+ value: f.value,
44
+ // Bitwarden uses numeric "type" for custom fields:
45
+ // 0 = text, 1 = hidden.
46
+ type: f.hidden ? 1 : 0,
47
+ }));
48
+ }
49
+ function normalizeUris(uris) {
50
+ if (!uris)
51
+ return undefined;
52
+ return uris.map((u) => ({
53
+ uri: u.uri,
54
+ match: u.match ? URI_MATCH[u.match] : null,
55
+ }));
56
+ }
57
+ function denormalizeUris(raw) {
58
+ if (!Array.isArray(raw))
59
+ return undefined;
60
+ const out = [];
61
+ for (const u of raw) {
62
+ if (!u || typeof u !== 'object')
63
+ continue;
64
+ const rec = u;
65
+ const uri = rec.uri;
66
+ if (typeof uri !== 'string' || uri.length === 0)
67
+ continue;
68
+ const match = typeof rec.match === 'number' ? URI_MATCH_REVERSE[rec.match] : undefined;
69
+ out.push({ uri, match });
70
+ }
71
+ return out;
72
+ }
73
+ function kindFromItem(item) {
74
+ const type = item.type;
75
+ if (type === ITEM_TYPE.login)
76
+ return 'login';
77
+ if (type === ITEM_TYPE.card)
78
+ return 'card';
79
+ if (type === ITEM_TYPE.identity)
80
+ return 'identity';
81
+ if (type === ITEM_TYPE.note)
82
+ return isSshKeyItem(item) ? 'ssh_key' : 'note';
83
+ return 'note';
84
+ }
85
+ function isSshKeyItem(item) {
86
+ if (item.type !== ITEM_TYPE.note)
87
+ return false;
88
+ const fields = item.fields;
89
+ if (!Array.isArray(fields))
90
+ return false;
91
+ const names = new Set(fields
92
+ .map((f) => (f && typeof f === 'object' ? f.name : null))
93
+ .filter((n) => typeof n === 'string'));
94
+ return names.has('public_key') && names.has('private_key');
95
+ }
96
+ export class KeychainSdk {
97
+ bw;
98
+ constructor(bw) {
99
+ this.bw = bw;
100
+ }
101
+ async createLoginForSession(session, input) {
102
+ const tpl = (await this.bw.getTemplateItemForSession(session));
103
+ const item = deepClone(tpl);
104
+ item.type = ITEM_TYPE.login;
105
+ item.name = input.name;
106
+ item.notes = input.notes ?? '';
107
+ item.favorite = input.favorite ?? false;
108
+ if (input.organizationId)
109
+ item.organizationId = input.organizationId;
110
+ if (input.folderId)
111
+ item.folderId = input.folderId;
112
+ item.fields = normalizeFields(input.fields) ?? [];
113
+ const login = (item.login && typeof item.login === 'object'
114
+ ? item.login
115
+ : {});
116
+ if (input.username !== undefined)
117
+ login.username = input.username;
118
+ if (input.password !== undefined)
119
+ login.password = input.password;
120
+ if (input.totp !== undefined)
121
+ login.totp = input.totp;
122
+ if (input.uris !== undefined)
123
+ login.uris = normalizeUris(input.uris);
124
+ item.login = login;
125
+ // Set collectionIds optimistically; we'll also enforce with item-collections edit.
126
+ if (input.collectionIds)
127
+ item.collectionIds = input.collectionIds;
128
+ const encoded = encodeJsonForBw(item);
129
+ const { stdout } = await this.bw.runForSession(session, ['create', 'item', encoded], { timeoutMs: 120_000 });
130
+ const created = JSON.parse(stdout);
131
+ if (input.attachments?.length) {
132
+ const dir = await mkdtemp(join(tmpdir(), 'keychain-attach-'));
133
+ try {
134
+ for (const att of input.attachments) {
135
+ const safeBase = basename(att.filename || 'attachment.bin').replace(/[^A-Za-z0-9._-]+/g, '_');
136
+ const safeName = safeBase.length > 0 ? safeBase : 'attachment.bin';
137
+ const p = join(dir, safeName);
138
+ await writeFile(p, Buffer.from(att.contentBase64, 'base64'));
139
+ const { stdout: attOut } = await this.bw.runForSession(session, [
140
+ 'create',
141
+ 'attachment',
142
+ '--file',
143
+ p,
144
+ '--itemid',
145
+ String(created.id),
146
+ ], { timeoutMs: 120_000 });
147
+ // IMPORTANT: bw may return the full item JSON (including secrets) here.
148
+ // Never parse or include it in our response.
149
+ void attOut;
150
+ }
151
+ }
152
+ finally {
153
+ await rm(dir, { recursive: true, force: true });
154
+ }
155
+ }
156
+ if (input.collectionIds?.length) {
157
+ const encodedCols = encodeJsonForBw(input.collectionIds);
158
+ await this.bw
159
+ .runForSession(session, ['edit', 'item-collections', String(created.id), encodedCols], { timeoutMs: 120_000 })
160
+ .catch(() => { });
161
+ }
162
+ // Refetch so attachments metadata is accurate, but redact secrets by default.
163
+ const { stdout: gotOut } = await this.bw.runForSession(session, ['get', 'item', String(created.id)], { timeoutMs: 60_000 });
164
+ const got = JSON.parse(gotOut);
165
+ return this.maybeRedact(got, input.reveal);
166
+ }
167
+ syncOnWrite() {
168
+ return ((process.env.KEYCHAIN_SYNC_ON_WRITE ?? 'true').toLowerCase() === 'true');
169
+ }
170
+ maybeRedact(value, reveal) {
171
+ return (reveal ? value : redactItem(value));
172
+ }
173
+ valueResult(value, revealed) {
174
+ return { value, revealed };
175
+ }
176
+ tryParseJson(stdout) {
177
+ const trimmed = stdout.trim();
178
+ if (!trimmed)
179
+ return '';
180
+ try {
181
+ return JSON.parse(trimmed);
182
+ }
183
+ catch {
184
+ return trimmed;
185
+ }
186
+ }
187
+ async readSingleFileAsBase64(dir) {
188
+ const files = await readdir(dir);
189
+ if (files.length !== 1) {
190
+ throw new Error(`Expected exactly 1 downloaded file, found ${files.length}`);
191
+ }
192
+ const filename = files[0] ?? '';
193
+ const buf = await readFile(join(dir, filename));
194
+ return {
195
+ filename,
196
+ bytes: buf.byteLength,
197
+ contentBase64: buf.toString('base64'),
198
+ };
199
+ }
200
+ redactPasswordHistoryForTool(history) {
201
+ // For secret-returning tools we avoid returning sentinel strings like "[REDACTED]"
202
+ // because downstream utilities might accidentally pass them through.
203
+ return history.map((h) => {
204
+ if (!h || typeof h !== 'object')
205
+ return h;
206
+ const rec = { ...h };
207
+ if (typeof rec.password === 'string')
208
+ rec.password = null;
209
+ return rec;
210
+ });
211
+ }
212
+ async status() {
213
+ return this.bw.status();
214
+ }
215
+ async encode(input) {
216
+ // `bw encode` base64-encodes stdin.
217
+ const { stdout } = await this.bw.withSession(async (session) => {
218
+ return this.bw.runForSession(session, ['encode'], {
219
+ stdin: `${input.value}\n`,
220
+ timeoutMs: 30_000,
221
+ });
222
+ });
223
+ return { encoded: stdout.trim() };
224
+ }
225
+ async generate(input = {}) {
226
+ if (!input.reveal)
227
+ return this.valueResult(null, false);
228
+ const args = buildBwGenerateArgs(input);
229
+ const { stdout } = await this.bw.withSession(async (session) => this.bw.runForSession(session, args, { timeoutMs: 30_000 }));
230
+ return this.valueResult(stdout.trim(), true);
231
+ }
232
+ async generateUsername(input = {}) {
233
+ if (!input.reveal)
234
+ return this.valueResult(null, false);
235
+ const value = generateUsername(input);
236
+ return this.valueResult(value, true);
237
+ }
238
+ async getAttachment(input) {
239
+ return this.bw.withSession(async (session) => {
240
+ const dir = await mkdtemp(join(tmpdir(), 'keychain-attachment-'));
241
+ try {
242
+ await this.bw.runForSession(session, [
243
+ 'get',
244
+ 'attachment',
245
+ input.attachmentId,
246
+ '--itemid',
247
+ input.itemId,
248
+ '--output',
249
+ dir,
250
+ ], { timeoutMs: 120_000 });
251
+ return await this.readSingleFileAsBase64(dir);
252
+ }
253
+ finally {
254
+ await rm(dir, { recursive: true, force: true });
255
+ }
256
+ });
257
+ }
258
+ async sendList() {
259
+ return this.bw.withSession(async (session) => {
260
+ const { stdout } = await this.bw.runForSession(session, ['send', 'list'], {
261
+ timeoutMs: 60_000,
262
+ });
263
+ return this.tryParseJson(stdout);
264
+ });
265
+ }
266
+ async sendTemplate(input) {
267
+ return this.bw.withSession(async (session) => {
268
+ const { stdout } = await this.bw.runForSession(session, ['send', 'template', input.object], { timeoutMs: 60_000 });
269
+ return this.tryParseJson(stdout);
270
+ });
271
+ }
272
+ async sendGet(input) {
273
+ return this.bw.withSession(async (session) => {
274
+ if (input.text) {
275
+ const { stdout } = await this.bw.runForSession(session, ['--raw', 'send', 'get', input.id, '--text'], { timeoutMs: 60_000 });
276
+ return { text: stdout.trim() };
277
+ }
278
+ if (input.downloadFile) {
279
+ const dir = await mkdtemp(join(tmpdir(), 'keychain-sendfile-'));
280
+ try {
281
+ await this.bw.runForSession(session, ['send', 'get', input.id, '--output', dir], { timeoutMs: 120_000 });
282
+ const file = await this.readSingleFileAsBase64(dir);
283
+ return { file };
284
+ }
285
+ finally {
286
+ await rm(dir, { recursive: true, force: true });
287
+ }
288
+ }
289
+ const { stdout } = await this.bw.runForSession(session, ['send', 'get', input.id], {
290
+ timeoutMs: 60_000,
291
+ });
292
+ return this.tryParseJson(stdout);
293
+ });
294
+ }
295
+ async sendRemovePassword(input) {
296
+ return this.bw.withSession(async (session) => {
297
+ const { stdout } = await this.bw.runForSession(session, ['send', 'remove-password', input.id], { timeoutMs: 60_000 });
298
+ return this.tryParseJson(stdout);
299
+ });
300
+ }
301
+ async sendDelete(input) {
302
+ return this.bw.withSession(async (session) => {
303
+ const { stdout } = await this.bw.runForSession(session, ['send', 'delete', input.id], {
304
+ timeoutMs: 60_000,
305
+ });
306
+ return this.tryParseJson(stdout);
307
+ });
308
+ }
309
+ async sendCreate(input) {
310
+ return this.bw.withSession(async (session) => {
311
+ const args = ['send'];
312
+ if (input.type === 'file')
313
+ args.push('--file');
314
+ if (typeof input.deleteInDays === 'number')
315
+ args.push('--deleteInDays', String(input.deleteInDays));
316
+ if (typeof input.password === 'string')
317
+ args.push('--password', input.password);
318
+ if (typeof input.maxAccessCount === 'number')
319
+ args.push('--maxAccessCount', String(input.maxAccessCount));
320
+ if (input.hidden)
321
+ args.push('--hidden');
322
+ if (typeof input.name === 'string')
323
+ args.push('--name', input.name);
324
+ if (typeof input.notes === 'string')
325
+ args.push('--notes', input.notes);
326
+ if (input.fullObject)
327
+ args.push('--fullObject');
328
+ if (input.type === 'text') {
329
+ if (typeof input.text !== 'string')
330
+ throw new Error('Missing text for text send');
331
+ const { stdout } = await this.bw.runForSession(session, ['--raw', ...args, input.text], { timeoutMs: 60_000 });
332
+ return this.tryParseJson(stdout);
333
+ }
334
+ if (typeof input.filename !== 'string' ||
335
+ typeof input.contentBase64 !== 'string') {
336
+ throw new Error('Missing filename/contentBase64 for file send');
337
+ }
338
+ const dir = await mkdtemp(join(tmpdir(), 'keychain-send-create-'));
339
+ const filePath = join(dir, basename(input.filename));
340
+ try {
341
+ await writeFile(filePath, Buffer.from(input.contentBase64, 'base64'));
342
+ const { stdout } = await this.bw.runForSession(session, ['--raw', ...args, filePath], { timeoutMs: 120_000 });
343
+ return this.tryParseJson(stdout);
344
+ }
345
+ finally {
346
+ await rm(dir, { recursive: true, force: true });
347
+ }
348
+ });
349
+ }
350
+ async sendCreateEncoded(input) {
351
+ return this.bw.withSession(async (session) => {
352
+ let encodedJson = input.encodedJson;
353
+ if (!encodedJson && typeof input.json !== 'undefined') {
354
+ const enc = await this.encode({ value: JSON.stringify(input.json) });
355
+ encodedJson = enc.encoded;
356
+ }
357
+ if (typeof encodedJson !== 'string' &&
358
+ typeof input.text !== 'string' &&
359
+ typeof input.file === 'undefined') {
360
+ throw new Error('sendCreateEncoded requires one of: encodedJson, json, text, or file');
361
+ }
362
+ const args = ['send', 'create'];
363
+ if (typeof input.text === 'string')
364
+ args.push('--text', input.text);
365
+ if (input.hidden)
366
+ args.push('--hidden');
367
+ let dir = null;
368
+ try {
369
+ if (input.file) {
370
+ dir = await mkdtemp(join(tmpdir(), 'keychain-send-create-'));
371
+ const filePath = join(dir, basename(input.file.filename));
372
+ await writeFile(filePath, Buffer.from(input.file.contentBase64, 'base64'));
373
+ args.push('--file', filePath);
374
+ }
375
+ if (typeof encodedJson === 'string')
376
+ args.push(encodedJson);
377
+ const { stdout } = await this.bw.runForSession(session, args, {
378
+ timeoutMs: 120_000,
379
+ });
380
+ return this.tryParseJson(stdout);
381
+ }
382
+ finally {
383
+ if (dir)
384
+ await rm(dir, { recursive: true, force: true });
385
+ }
386
+ });
387
+ }
388
+ async sendEdit(input) {
389
+ return this.bw.withSession(async (session) => {
390
+ let encodedJson = input.encodedJson;
391
+ if (!encodedJson && typeof input.json !== 'undefined') {
392
+ const enc = await this.encode({ value: JSON.stringify(input.json) });
393
+ encodedJson = enc.encoded;
394
+ }
395
+ if (typeof encodedJson !== 'string') {
396
+ throw new Error('sendEdit requires encodedJson or json');
397
+ }
398
+ const args = ['send', 'edit'];
399
+ if (typeof input.itemId === 'string')
400
+ args.push('--itemid', input.itemId);
401
+ args.push(encodedJson);
402
+ const { stdout } = await this.bw.runForSession(session, args, {
403
+ timeoutMs: 120_000,
404
+ });
405
+ return this.tryParseJson(stdout);
406
+ });
407
+ }
408
+ async receive(input) {
409
+ return this.bw.withSession(async (session) => {
410
+ const args = ['receive', input.url];
411
+ if (typeof input.password === 'string')
412
+ args.push('--password', input.password);
413
+ if (input.obj) {
414
+ const { stdout } = await this.bw.runForSession(session, ['--raw', ...args, '--obj'], { timeoutMs: 60_000 });
415
+ return this.tryParseJson(stdout);
416
+ }
417
+ if (input.downloadFile) {
418
+ const dir = await mkdtemp(join(tmpdir(), 'keychain-receive-'));
419
+ const outPath = join(dir, 'received');
420
+ try {
421
+ await this.bw.runForSession(session, [...args, '--output', outPath], {
422
+ timeoutMs: 120_000,
423
+ });
424
+ const buf = await readFile(outPath);
425
+ return {
426
+ file: {
427
+ filename: basename(outPath),
428
+ bytes: buf.byteLength,
429
+ contentBase64: buf.toString('base64'),
430
+ },
431
+ };
432
+ }
433
+ finally {
434
+ await rm(dir, { recursive: true, force: true });
435
+ }
436
+ }
437
+ const { stdout } = await this.bw.runForSession(session, ['--raw', ...args], {
438
+ timeoutMs: 60_000,
439
+ });
440
+ return { text: stdout.trim() };
441
+ });
442
+ }
443
+ async searchItems(input) {
444
+ const { limit } = input;
445
+ const rawText = (input.text ?? '').trim();
446
+ const tokens = rawText.includes('|')
447
+ ? rawText
448
+ .split('|')
449
+ .map((s) => s.trim())
450
+ .filter((s) => s.length > 0)
451
+ : rawText.length > 0
452
+ ? [rawText]
453
+ : [];
454
+ const orgFilter = input.organizationId;
455
+ const orgId = orgFilter && orgFilter !== 'null' && orgFilter !== 'notnull'
456
+ ? orgFilter
457
+ : undefined;
458
+ const folderFilter = input.folderId;
459
+ const folderId = folderFilter && folderFilter !== 'null' && folderFilter !== 'notnull'
460
+ ? folderFilter
461
+ : undefined;
462
+ const items = await this.bw.withSession(async (session) => {
463
+ const baseArgs = ['list', 'items'];
464
+ if (input.url)
465
+ baseArgs.push('--url', input.url);
466
+ if (folderId)
467
+ baseArgs.push('--folderid', folderId);
468
+ if (input.collectionId)
469
+ baseArgs.push('--collectionid', input.collectionId);
470
+ if (orgId)
471
+ baseArgs.push('--organizationid', orgId);
472
+ if (input.trash)
473
+ baseArgs.push('--trash');
474
+ // NOTE: bw's `--search` does not treat "a | b" as "a OR b". If callers pass
475
+ // a pipe-delimited string (common when combining name + username), we split
476
+ // and union the results.
477
+ const terms = tokens.length ? tokens : [undefined];
478
+ const byId = new Map();
479
+ for (const term of terms) {
480
+ const args = [...baseArgs];
481
+ if (term)
482
+ args.push('--search', term);
483
+ const { stdout } = await this.bw.runForSession(session, args, {
484
+ timeoutMs: 120_000,
485
+ });
486
+ const results = JSON.parse(stdout);
487
+ for (const raw of results) {
488
+ if (!raw || typeof raw !== 'object')
489
+ continue;
490
+ const id = raw.id;
491
+ if (typeof id === 'string' && id.length > 0)
492
+ byId.set(id, raw);
493
+ }
494
+ }
495
+ return [...byId.values()];
496
+ });
497
+ const orgFiltered = items.filter((raw) => {
498
+ if (!raw || typeof raw !== 'object')
499
+ return false;
500
+ const item = raw;
501
+ if (orgFilter === 'null') {
502
+ return item.organizationId == null;
503
+ }
504
+ if (orgFilter === 'notnull') {
505
+ return typeof item.organizationId === 'string' && item.organizationId;
506
+ }
507
+ return true;
508
+ });
509
+ const folderFiltered = orgFiltered.filter((raw) => {
510
+ if (!raw || typeof raw !== 'object')
511
+ return false;
512
+ const item = raw;
513
+ if (folderFilter === 'null') {
514
+ return item.folderId == null;
515
+ }
516
+ if (folderFilter === 'notnull') {
517
+ return typeof item.folderId === 'string' && item.folderId;
518
+ }
519
+ return true;
520
+ });
521
+ const filtered = folderFiltered.filter((raw) => {
522
+ if (!raw || typeof raw !== 'object')
523
+ return false;
524
+ const item = raw;
525
+ if (!input.type)
526
+ return true;
527
+ if (input.type === 'ssh_key')
528
+ return isSshKeyItem(item);
529
+ if (input.type === 'login')
530
+ return item.type === ITEM_TYPE.login;
531
+ if (input.type === 'card')
532
+ return item.type === ITEM_TYPE.card;
533
+ if (input.type === 'identity')
534
+ return item.type === ITEM_TYPE.identity;
535
+ if (input.type === 'note')
536
+ return item.type === ITEM_TYPE.note && !isSshKeyItem(item);
537
+ return true;
538
+ });
539
+ return typeof limit === 'number' ? filtered.slice(0, limit) : filtered;
540
+ }
541
+ async listFolders(input = {}) {
542
+ const { limit } = input;
543
+ const folders = await this.bw.withSession(async (session) => {
544
+ const args = ['list', 'folders'];
545
+ if (input.search)
546
+ args.push('--search', input.search);
547
+ const { stdout } = await this.bw.runForSession(session, args, {
548
+ timeoutMs: 60_000,
549
+ });
550
+ return JSON.parse(stdout);
551
+ });
552
+ return typeof limit === 'number' ? folders.slice(0, limit) : folders;
553
+ }
554
+ async listCollections(input = {}) {
555
+ const { limit } = input;
556
+ const collections = await this.bw.withSession(async (session) => {
557
+ const args = ['list', 'collections'];
558
+ if (input.search)
559
+ args.push('--search', input.search);
560
+ if (input.organizationId)
561
+ args.push('--organizationid', input.organizationId);
562
+ const { stdout } = await this.bw.runForSession(session, args, {
563
+ timeoutMs: 60_000,
564
+ });
565
+ return JSON.parse(stdout);
566
+ });
567
+ return typeof limit === 'number'
568
+ ? collections.slice(0, limit)
569
+ : collections;
570
+ }
571
+ async listOrganizations(input = {}) {
572
+ const { limit } = input;
573
+ const orgs = await this.bw.withSession(async (session) => {
574
+ const args = ['list', 'organizations'];
575
+ if (input.search)
576
+ args.push('--search', input.search);
577
+ const { stdout } = await this.bw.runForSession(session, args, {
578
+ timeoutMs: 60_000,
579
+ });
580
+ return JSON.parse(stdout);
581
+ });
582
+ return typeof limit === 'number' ? orgs.slice(0, limit) : orgs;
583
+ }
584
+ async createFolder(name) {
585
+ return this.bw.withSession(async (session) => {
586
+ if (this.syncOnWrite()) {
587
+ await this.bw
588
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
589
+ .catch(() => { });
590
+ }
591
+ const encoded = encodeJsonForBw({ name });
592
+ const { stdout } = await this.bw.runForSession(session, ['create', 'folder', encoded], { timeoutMs: 60_000 });
593
+ return JSON.parse(stdout);
594
+ });
595
+ }
596
+ async editFolder(input) {
597
+ return this.bw.withSession(async (session) => {
598
+ if (this.syncOnWrite()) {
599
+ await this.bw
600
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
601
+ .catch(() => { });
602
+ }
603
+ const encoded = encodeJsonForBw({ name: input.name });
604
+ const { stdout } = await this.bw.runForSession(session, ['edit', 'folder', input.id, encoded], { timeoutMs: 60_000 });
605
+ return JSON.parse(stdout);
606
+ });
607
+ }
608
+ async deleteFolder(input) {
609
+ return this.bw.withSession(async (session) => {
610
+ if (this.syncOnWrite()) {
611
+ await this.bw
612
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
613
+ .catch(() => { });
614
+ }
615
+ await this.bw.runForSession(session, ['delete', 'folder', input.id], {
616
+ timeoutMs: 60_000,
617
+ });
618
+ });
619
+ }
620
+ async listOrgCollections(input) {
621
+ const { limit } = input;
622
+ const cols = await this.bw.withSession(async (session) => {
623
+ const args = [
624
+ 'list',
625
+ 'org-collections',
626
+ '--organizationid',
627
+ input.organizationId,
628
+ ];
629
+ if (input.search)
630
+ args.push('--search', input.search);
631
+ const { stdout } = await this.bw.runForSession(session, args, {
632
+ timeoutMs: 60_000,
633
+ });
634
+ return JSON.parse(stdout);
635
+ });
636
+ return typeof limit === 'number' ? cols.slice(0, limit) : cols;
637
+ }
638
+ async createOrgCollection(input) {
639
+ return this.bw.withSession(async (session) => {
640
+ if (this.syncOnWrite()) {
641
+ await this.bw
642
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
643
+ .catch(() => { });
644
+ }
645
+ // Newer bw CLI versions validate that --organizationid matches the request payload.
646
+ const encoded = encodeJsonForBw({
647
+ name: input.name,
648
+ organizationId: input.organizationId,
649
+ });
650
+ const { stdout } = await this.bw.runForSession(session, [
651
+ 'create',
652
+ 'org-collection',
653
+ '--organizationid',
654
+ input.organizationId,
655
+ encoded,
656
+ ], { timeoutMs: 60_000 });
657
+ return JSON.parse(stdout);
658
+ });
659
+ }
660
+ async editOrgCollection(input) {
661
+ return this.bw.withSession(async (session) => {
662
+ if (this.syncOnWrite()) {
663
+ await this.bw
664
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
665
+ .catch(() => { });
666
+ }
667
+ // Newer bw CLI versions validate that --organizationid matches the request payload.
668
+ const encoded = encodeJsonForBw({
669
+ id: input.id,
670
+ name: input.name,
671
+ organizationId: input.organizationId,
672
+ });
673
+ const { stdout } = await this.bw.runForSession(session, [
674
+ 'edit',
675
+ 'org-collection',
676
+ input.id,
677
+ encoded,
678
+ '--organizationid',
679
+ input.organizationId,
680
+ ], { timeoutMs: 60_000 });
681
+ return JSON.parse(stdout);
682
+ });
683
+ }
684
+ async deleteOrgCollection(input) {
685
+ return this.bw.withSession(async (session) => {
686
+ if (this.syncOnWrite()) {
687
+ await this.bw
688
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
689
+ .catch(() => { });
690
+ }
691
+ await this.bw.runForSession(session, [
692
+ 'delete',
693
+ 'org-collection',
694
+ input.id,
695
+ '--organizationid',
696
+ input.organizationId,
697
+ ], { timeoutMs: 60_000 });
698
+ });
699
+ }
700
+ async moveItemToOrganization(input) {
701
+ return this.bw.withSession(async (session) => {
702
+ if (this.syncOnWrite()) {
703
+ await this.bw
704
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
705
+ .catch(() => { });
706
+ }
707
+ const args = ['move', input.id, input.organizationId];
708
+ if (input.collectionIds) {
709
+ args.push(encodeJsonForBw(input.collectionIds));
710
+ }
711
+ const { stdout } = await this.bw.runForSession(session, args, {
712
+ timeoutMs: 120_000,
713
+ });
714
+ const moved = JSON.parse(stdout);
715
+ return this.maybeRedact(moved, input.reveal);
716
+ });
717
+ }
718
+ async getItem(id, opts = {}) {
719
+ const item = await this.bw.withSession(async (session) => {
720
+ const { stdout } = await this.bw.runForSession(session, ['get', 'item', id], { timeoutMs: 60_000 });
721
+ return JSON.parse(stdout);
722
+ });
723
+ return this.maybeRedact(item, opts.reveal);
724
+ }
725
+ async deleteItem(input) {
726
+ return this.bw.withSession(async (session) => {
727
+ if (this.syncOnWrite()) {
728
+ await this.bw
729
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
730
+ .catch(() => { });
731
+ }
732
+ const args = ['delete', 'item', input.id];
733
+ if (input.permanent)
734
+ args.push('--permanent');
735
+ await this.bw.runForSession(session, args, { timeoutMs: 60_000 });
736
+ });
737
+ }
738
+ async deleteItems(input) {
739
+ if (input.ids.length === 0)
740
+ return [];
741
+ if (input.ids.length > 200)
742
+ throw new Error('Too many ids (max 200)');
743
+ // Run inside a single session lock to avoid re-syncing/unlocking per item.
744
+ return this.bw.withSession(async (session) => {
745
+ if (this.syncOnWrite()) {
746
+ await this.bw
747
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
748
+ .catch(() => { });
749
+ }
750
+ const results = [];
751
+ for (const id of input.ids) {
752
+ try {
753
+ const args = ['delete', 'item', id];
754
+ if (input.permanent)
755
+ args.push('--permanent');
756
+ await this.bw.runForSession(session, args, { timeoutMs: 60_000 });
757
+ results.push({ id, ok: true });
758
+ }
759
+ catch (e) {
760
+ results.push({
761
+ id,
762
+ ok: false,
763
+ error: e instanceof Error ? e.message : String(e),
764
+ });
765
+ }
766
+ }
767
+ return results;
768
+ });
769
+ }
770
+ async restoreItem(input) {
771
+ return this.bw.withSession(async (session) => {
772
+ if (this.syncOnWrite()) {
773
+ await this.bw
774
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
775
+ .catch(() => { });
776
+ }
777
+ const { stdout } = await this.bw.runForSession(session, ['restore', 'item', input.id], { timeoutMs: 60_000 });
778
+ // restore may not return JSON; ignore stdout and refetch.
779
+ void stdout;
780
+ const { stdout: gotOut } = await this.bw.runForSession(session, ['get', 'item', input.id], { timeoutMs: 60_000 });
781
+ return this.maybeRedact(JSON.parse(gotOut), input.reveal);
782
+ });
783
+ }
784
+ async createAttachment(input) {
785
+ return this.bw.withSession(async (session) => {
786
+ if (this.syncOnWrite()) {
787
+ await this.bw
788
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
789
+ .catch(() => { });
790
+ }
791
+ const dir = await mkdtemp(join(tmpdir(), 'keychain-attach-'));
792
+ try {
793
+ const safeBase = basename(input.filename || 'attachment.bin').replace(/[^A-Za-z0-9._-]+/g, '_');
794
+ const safeName = safeBase.length > 0 ? safeBase : 'attachment.bin';
795
+ const p = join(dir, safeName);
796
+ await writeFile(p, Buffer.from(input.contentBase64, 'base64'));
797
+ const { stdout: out } = await this.bw.runForSession(session, ['create', 'attachment', '--file', p, '--itemid', input.itemId], { timeoutMs: 120_000 });
798
+ // bw may return a full item JSON; ignore it and refetch the item.
799
+ void out;
800
+ }
801
+ finally {
802
+ await rm(dir, { recursive: true, force: true });
803
+ }
804
+ const { stdout: gotOut } = await this.bw.runForSession(session, ['get', 'item', input.itemId], { timeoutMs: 60_000 });
805
+ return this.maybeRedact(JSON.parse(gotOut), input.reveal);
806
+ });
807
+ }
808
+ async deleteAttachment(input) {
809
+ return this.bw.withSession(async (session) => {
810
+ if (this.syncOnWrite()) {
811
+ await this.bw
812
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
813
+ .catch(() => { });
814
+ }
815
+ await this.bw.runForSession(session, ['delete', 'attachment', input.attachmentId, '--itemid', input.itemId], { timeoutMs: 60_000 });
816
+ const { stdout: gotOut } = await this.bw.runForSession(session, ['get', 'item', input.itemId], { timeoutMs: 60_000 });
817
+ return this.maybeRedact(JSON.parse(gotOut), input.reveal);
818
+ });
819
+ }
820
+ async getUsername(input) {
821
+ return this.bw.withSession(async (session) => {
822
+ const { stdout } = await this.bw.runForSession(session, ['--raw', 'get', 'username', input.term], { timeoutMs: 60_000 });
823
+ return this.valueResult(stdout.trim(), true);
824
+ });
825
+ }
826
+ async getPassword(input, opts = {}) {
827
+ if (!opts.reveal)
828
+ return this.valueResult(null, false);
829
+ return this.bw.withSession(async (session) => {
830
+ const { stdout } = await this.bw.runForSession(session, ['--raw', 'get', 'password', input.term], { timeoutMs: 60_000 });
831
+ return this.valueResult(stdout.trim(), true);
832
+ });
833
+ }
834
+ async getTotp(input, opts = {}) {
835
+ if (!opts.reveal)
836
+ return this.valueResult(null, false);
837
+ return this.bw.withSession(async (session) => {
838
+ const { stdout } = await this.bw.runForSession(session, ['--raw', 'get', 'totp', input.term], { timeoutMs: 60_000 });
839
+ return this.valueResult(stdout.trim(), true);
840
+ });
841
+ }
842
+ async getUri(input) {
843
+ return this.bw.withSession(async (session) => {
844
+ const { stdout } = await this.bw.runForSession(session, ['--raw', 'get', 'uri', input.term], { timeoutMs: 60_000 });
845
+ return this.valueResult(stdout.trim(), true);
846
+ });
847
+ }
848
+ async getNotes(input, opts = {}) {
849
+ if (!opts.reveal)
850
+ return this.valueResult(null, false);
851
+ return this.bw.withSession(async (session) => {
852
+ const { stdout } = await this.bw.runForSession(session, ['--raw', 'get', 'notes', input.term], { timeoutMs: 60_000 });
853
+ return this.valueResult(stdout.trim(), true);
854
+ });
855
+ }
856
+ async getExposed(input) {
857
+ const isNotFoundError = (err) => {
858
+ const combined = `${err.stderr}\n${err.stdout}`.trim().toLowerCase();
859
+ if (/more than one result/.test(combined)) {
860
+ return false;
861
+ }
862
+ if (/could not connect/.test(combined) ||
863
+ /connection/.test(combined) ||
864
+ /network/.test(combined) ||
865
+ /timeout/.test(combined) ||
866
+ /unauthorized|forbidden|not logged|authentication|permission/.test(combined)) {
867
+ return false;
868
+ }
869
+ return true;
870
+ };
871
+ return this.bw.withSession(async (session) => {
872
+ try {
873
+ const { stdout } = await this.bw.runForSession(session, ['--raw', 'get', 'exposed', input.term], { timeoutMs: 60_000 });
874
+ return this.valueResult(stdout.trim(), true);
875
+ }
876
+ catch (err) {
877
+ if ((err instanceof BwCliError &&
878
+ err.exitCode === 1 &&
879
+ isNotFoundError(err)) ||
880
+ (err instanceof Error &&
881
+ /exit code 1/.test(err.message.toLowerCase()))) {
882
+ return this.valueResult(null, false);
883
+ }
884
+ throw err;
885
+ }
886
+ });
887
+ }
888
+ async getFolder(input) {
889
+ return this.bw.withSession(async (session) => {
890
+ const { stdout } = await this.bw.runForSession(session, ['get', 'folder', input.id], { timeoutMs: 60_000 });
891
+ return JSON.parse(stdout);
892
+ });
893
+ }
894
+ async getCollection(input) {
895
+ return this.bw.withSession(async (session) => {
896
+ const args = ['get', 'collection', input.id];
897
+ if (input.organizationId)
898
+ args.push('--organizationid', input.organizationId);
899
+ const { stdout } = await this.bw.runForSession(session, args, {
900
+ timeoutMs: 60_000,
901
+ });
902
+ return JSON.parse(stdout);
903
+ });
904
+ }
905
+ async getOrganization(input) {
906
+ return this.bw.withSession(async (session) => {
907
+ const { stdout } = await this.bw.runForSession(session, ['get', 'organization', input.id], { timeoutMs: 60_000 });
908
+ return JSON.parse(stdout);
909
+ });
910
+ }
911
+ async getOrgCollection(input) {
912
+ return this.bw.withSession(async (session) => {
913
+ const args = ['get', 'org-collection', input.id];
914
+ if (input.organizationId)
915
+ args.push('--organizationid', input.organizationId);
916
+ const { stdout } = await this.bw.runForSession(session, args, {
917
+ timeoutMs: 60_000,
918
+ });
919
+ return JSON.parse(stdout);
920
+ });
921
+ }
922
+ async getPasswordHistory(id, opts = {}) {
923
+ const item = await this.bw.withSession(async (session) => {
924
+ const { stdout } = await this.bw.runForSession(session, ['get', 'item', id], { timeoutMs: 60_000 });
925
+ return JSON.parse(stdout);
926
+ });
927
+ const history = Array.isArray(item.passwordHistory)
928
+ ? item.passwordHistory
929
+ : [];
930
+ if (opts.reveal)
931
+ return { value: history, revealed: true };
932
+ return {
933
+ value: this.redactPasswordHistoryForTool(history),
934
+ revealed: false,
935
+ };
936
+ }
937
+ async createLogin(input) {
938
+ return this.bw.withSession(async (session) => {
939
+ if (this.syncOnWrite()) {
940
+ await this.bw
941
+ .runForSession(session, ['sync'], {
942
+ timeoutMs: 120_000,
943
+ })
944
+ .catch(() => { });
945
+ }
946
+ return this.createLoginForSession(session, input);
947
+ });
948
+ }
949
+ async createLogins(input) {
950
+ const continueOnError = input.continueOnError ?? true;
951
+ return this.bw.withSession(async (session) => {
952
+ if (this.syncOnWrite()) {
953
+ await this.bw
954
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
955
+ .catch(() => { });
956
+ }
957
+ const results = [];
958
+ for (const it of input.items) {
959
+ try {
960
+ const created = await this.createLoginForSession(session, it);
961
+ results.push({ ok: true, item: created });
962
+ }
963
+ catch (err) {
964
+ const msg = err && typeof err === 'object' && 'message' in err
965
+ ? String(err.message)
966
+ : String(err);
967
+ results.push({ ok: false, error: msg });
968
+ if (!continueOnError)
969
+ break;
970
+ }
971
+ }
972
+ return results;
973
+ });
974
+ }
975
+ async createNote(input) {
976
+ return this.bw.withSession(async (session) => {
977
+ if (this.syncOnWrite()) {
978
+ await this.bw
979
+ .runForSession(session, ['sync'], {
980
+ timeoutMs: 120_000,
981
+ })
982
+ .catch(() => { });
983
+ }
984
+ const tpl = (await this.bw.getTemplateItemForSession(session));
985
+ const item = deepClone(tpl);
986
+ item.type = ITEM_TYPE.note;
987
+ item.name = input.name;
988
+ item.notes = input.notes ?? '';
989
+ item.favorite = input.favorite ?? false;
990
+ if (input.organizationId)
991
+ item.organizationId = input.organizationId;
992
+ if (input.folderId)
993
+ item.folderId = input.folderId;
994
+ if (!item.secureNote || typeof item.secureNote !== 'object') {
995
+ item.secureNote = { type: 0 };
996
+ }
997
+ item.fields = normalizeFields(input.fields) ?? [];
998
+ if (input.collectionIds)
999
+ item.collectionIds = input.collectionIds;
1000
+ const encoded = encodeJsonForBw(item);
1001
+ const { stdout } = await this.bw.runForSession(session, ['create', 'item', encoded], { timeoutMs: 120_000 });
1002
+ const created = JSON.parse(stdout);
1003
+ if (input.collectionIds?.length) {
1004
+ const encodedCols = encodeJsonForBw(input.collectionIds);
1005
+ await this.bw
1006
+ .runForSession(session, ['edit', 'item-collections', String(created.id), encodedCols], { timeoutMs: 120_000 })
1007
+ .catch(() => { });
1008
+ }
1009
+ return this.maybeRedact(created, input.reveal);
1010
+ });
1011
+ }
1012
+ async createSshKey(input) {
1013
+ const fields = [
1014
+ { name: 'public_key', value: input.publicKey, hidden: false },
1015
+ { name: 'private_key', value: input.privateKey, hidden: true },
1016
+ ];
1017
+ if (input.fingerprint)
1018
+ fields.push({
1019
+ name: 'fingerprint',
1020
+ value: input.fingerprint,
1021
+ hidden: false,
1022
+ });
1023
+ if (input.comment)
1024
+ fields.push({ name: 'comment', value: input.comment, hidden: false });
1025
+ return this.createNote({
1026
+ name: input.name,
1027
+ notes: input.notes,
1028
+ reveal: input.reveal,
1029
+ favorite: input.favorite,
1030
+ organizationId: input.organizationId,
1031
+ collectionIds: input.collectionIds,
1032
+ folderId: input.folderId,
1033
+ fields,
1034
+ });
1035
+ }
1036
+ async createCard(input) {
1037
+ return this.bw.withSession(async (session) => {
1038
+ if (this.syncOnWrite()) {
1039
+ await this.bw
1040
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
1041
+ .catch(() => { });
1042
+ }
1043
+ const tpl = (await this.bw.getTemplateItemForSession(session));
1044
+ const item = deepClone(tpl);
1045
+ item.type = ITEM_TYPE.card;
1046
+ item.name = input.name;
1047
+ item.notes = input.notes ?? '';
1048
+ item.favorite = input.favorite ?? false;
1049
+ if (input.organizationId)
1050
+ item.organizationId = input.organizationId;
1051
+ if (input.folderId)
1052
+ item.folderId = input.folderId;
1053
+ item.fields = normalizeFields(input.fields) ?? [];
1054
+ const card = (item.card && typeof item.card === 'object'
1055
+ ? item.card
1056
+ : {});
1057
+ if (input.cardholderName !== undefined)
1058
+ card.cardholderName = input.cardholderName;
1059
+ if (input.brand !== undefined)
1060
+ card.brand = input.brand;
1061
+ if (input.number !== undefined)
1062
+ card.number = input.number;
1063
+ if (input.expMonth !== undefined)
1064
+ card.expMonth = input.expMonth;
1065
+ if (input.expYear !== undefined)
1066
+ card.expYear = input.expYear;
1067
+ if (input.code !== undefined)
1068
+ card.code = input.code;
1069
+ item.card = card;
1070
+ if (input.collectionIds)
1071
+ item.collectionIds = input.collectionIds;
1072
+ const encoded = encodeJsonForBw(item);
1073
+ const { stdout } = await this.bw.runForSession(session, ['create', 'item', encoded], { timeoutMs: 120_000 });
1074
+ const created = JSON.parse(stdout);
1075
+ if (input.collectionIds?.length) {
1076
+ const encodedCols = encodeJsonForBw(input.collectionIds);
1077
+ await this.bw
1078
+ .runForSession(session, ['edit', 'item-collections', String(created.id), encodedCols], { timeoutMs: 120_000 })
1079
+ .catch(() => { });
1080
+ }
1081
+ return this.maybeRedact(created, input.reveal);
1082
+ });
1083
+ }
1084
+ async createIdentity(input) {
1085
+ return this.bw.withSession(async (session) => {
1086
+ if (this.syncOnWrite()) {
1087
+ await this.bw
1088
+ .runForSession(session, ['sync'], { timeoutMs: 120_000 })
1089
+ .catch(() => { });
1090
+ }
1091
+ const tpl = (await this.bw.getTemplateItemForSession(session));
1092
+ const item = deepClone(tpl);
1093
+ item.type = ITEM_TYPE.identity;
1094
+ item.name = input.name;
1095
+ item.notes = input.notes ?? '';
1096
+ item.favorite = input.favorite ?? false;
1097
+ if (input.organizationId)
1098
+ item.organizationId = input.organizationId;
1099
+ if (input.folderId)
1100
+ item.folderId = input.folderId;
1101
+ item.fields = normalizeFields(input.fields) ?? [];
1102
+ const identity = (item.identity && typeof item.identity === 'object'
1103
+ ? item.identity
1104
+ : {});
1105
+ if (input.identity) {
1106
+ for (const [k, v] of Object.entries(input.identity)) {
1107
+ if (v !== undefined)
1108
+ identity[k] = v;
1109
+ }
1110
+ }
1111
+ item.identity = identity;
1112
+ if (input.collectionIds)
1113
+ item.collectionIds = input.collectionIds;
1114
+ const encoded = encodeJsonForBw(item);
1115
+ const { stdout } = await this.bw.runForSession(session, ['create', 'item', encoded], { timeoutMs: 120_000 });
1116
+ const created = JSON.parse(stdout);
1117
+ if (input.collectionIds?.length) {
1118
+ const encodedCols = encodeJsonForBw(input.collectionIds);
1119
+ await this.bw
1120
+ .runForSession(session, ['edit', 'item-collections', String(created.id), encodedCols], { timeoutMs: 120_000 })
1121
+ .catch(() => { });
1122
+ }
1123
+ return this.maybeRedact(created, input.reveal);
1124
+ });
1125
+ }
1126
+ async updateItem(id, patch, opts = {}) {
1127
+ return this.bw.withSession(async (session) => {
1128
+ if (this.syncOnWrite()) {
1129
+ await this.bw
1130
+ .runForSession(session, ['sync'], {
1131
+ timeoutMs: 120_000,
1132
+ })
1133
+ .catch(() => { });
1134
+ }
1135
+ const { stdout } = await this.bw.runForSession(session, ['get', 'item', id], { timeoutMs: 60_000 });
1136
+ const current = JSON.parse(stdout);
1137
+ // Convert uri match strings to bw enum numbers if needed.
1138
+ const normalizedPatch = deepClone(patch);
1139
+ if (normalizedPatch.login?.uris) {
1140
+ normalizedPatch.login.uris = normalizedPatch.login.uris.map((u) => ({
1141
+ uri: u.uri,
1142
+ match: u.match,
1143
+ }));
1144
+ }
1145
+ const next = applyItemPatch(current, normalizedPatch);
1146
+ // If uris were provided, convert match strings to numbers now.
1147
+ if (patch.login?.uris) {
1148
+ const login = (next.login && typeof next.login === 'object'
1149
+ ? next.login
1150
+ : {});
1151
+ login.uris = normalizeUris(patch.login.uris);
1152
+ next.login = login;
1153
+ }
1154
+ const encoded = encodeJsonForBw(next);
1155
+ const { stdout: out } = await this.bw.runForSession(session, ['edit', 'item', id, encoded], { timeoutMs: 120_000 });
1156
+ const updated = JSON.parse(out);
1157
+ if (patch.collectionIds !== undefined) {
1158
+ const encodedCols = encodeJsonForBw(patch.collectionIds);
1159
+ await this.bw
1160
+ .runForSession(session, ['edit', 'item-collections', id, encodedCols], { timeoutMs: 120_000 })
1161
+ .catch(() => { });
1162
+ }
1163
+ return this.maybeRedact(updated, opts.reveal);
1164
+ });
1165
+ }
1166
+ async setLoginUris(input) {
1167
+ const mode = input.mode ?? 'replace';
1168
+ if (mode !== 'replace' && mode !== 'merge') {
1169
+ throw new Error(`Invalid mode: ${String(mode)}`);
1170
+ }
1171
+ // IMPORTANT: do not call updateItem from inside a bw.withSession callback.
1172
+ // BwSessionManager can serialize session access; nesting can deadlock.
1173
+ const current = await this.bw.withSession(async (session) => {
1174
+ const { stdout } = await this.bw.runForSession(session, ['get', 'item', input.id], { timeoutMs: 60_000 });
1175
+ return JSON.parse(stdout);
1176
+ });
1177
+ const currentLogin = current.login && typeof current.login === 'object'
1178
+ ? current.login
1179
+ : null;
1180
+ const existing = denormalizeUris(currentLogin?.uris) ?? [];
1181
+ let nextUris = input.uris;
1182
+ if (mode === 'merge') {
1183
+ const byUri = new Map();
1184
+ for (const u of existing)
1185
+ byUri.set(u.uri, u);
1186
+ for (const u of input.uris)
1187
+ byUri.set(u.uri, u);
1188
+ const seen = new Set();
1189
+ const merged = [];
1190
+ // Existing order first (updated in place if overridden)
1191
+ for (const u of existing) {
1192
+ const v = byUri.get(u.uri);
1193
+ if (!v || seen.has(v.uri))
1194
+ continue;
1195
+ seen.add(v.uri);
1196
+ merged.push(v);
1197
+ }
1198
+ // Append new entries
1199
+ for (const u of input.uris) {
1200
+ if (seen.has(u.uri))
1201
+ continue;
1202
+ seen.add(u.uri);
1203
+ merged.push(u);
1204
+ }
1205
+ nextUris = merged;
1206
+ }
1207
+ return this.updateItem(input.id, { login: { uris: nextUris } }, { reveal: input.reveal });
1208
+ }
1209
+ minimalSummary(item) {
1210
+ if (!item || typeof item !== 'object')
1211
+ return item;
1212
+ const rec = item;
1213
+ return {
1214
+ id: typeof rec.id === 'string' ? rec.id : undefined,
1215
+ name: typeof rec.name === 'string' ? rec.name : undefined,
1216
+ type: kindFromItem(rec),
1217
+ organizationId: typeof rec.organizationId === 'string' ? rec.organizationId : null,
1218
+ folderId: typeof rec.folderId === 'string' ? rec.folderId : null,
1219
+ collectionIds: Array.isArray(rec.collectionIds)
1220
+ ? rec.collectionIds
1221
+ : undefined,
1222
+ favorite: typeof rec.favorite === 'boolean' ? rec.favorite : undefined,
1223
+ };
1224
+ }
1225
+ }