@hunan36/dsh-ssh-helper 0.0.0-stage → 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,688 @@
1
+ /**
2
+ * Persistent SSH account store.
3
+ *
4
+ * Accounts contain credentials (password, private key and passphrase), so every
5
+ * public projection in this module deliberately drops them. Only
6
+ * `getSecretRecord` and `testDraft` return the full record, and both are
7
+ * host-side only. The file is written atomically to `accounts.json` with mode
8
+ * `0600`; a corrupt file is quarantined by `host/atomic.js`.
9
+ *
10
+ * @module host/accounts
11
+ */
12
+
13
+ import { randomUUID } from 'node:crypto'
14
+ import { readFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import path from 'node:path'
17
+ import { createJsonFile } from './atomic.js'
18
+
19
+ /** File name inside the plugin data directory. */
20
+ const DATA_FILE = 'accounts.json'
21
+ /** Maximum display name length. */
22
+ const NAME_MAX_LENGTH = 200
23
+ /** Maximum note length. */
24
+ const NOTE_MAX_LENGTH = 500
25
+ /** Default SSH port. */
26
+ const DEFAULT_PORT = 22
27
+ /** Supported authentication types. */
28
+ const AUTH_TYPES = new Set(['password', 'key'])
29
+
30
+ /**
31
+ * Resolve the plugin data directory: `DSH_SSH_HELPER_DIR`, then
32
+ * `<DSH_HOME>/ssh-helper`, then `~/.dsh/ssh-helper`.
33
+ * @returns {string} absolute data directory.
34
+ */
35
+ export function resolveDataDir() {
36
+ const explicit = process.env.DSH_SSH_HELPER_DIR
37
+ if (typeof explicit === 'string' && explicit.trim() !== '') return path.resolve(explicit.trim())
38
+ const home = process.env.DSH_HOME
39
+ if (typeof home === 'string' && home.trim() !== '') return path.join(path.resolve(home.trim()), 'ssh-helper')
40
+ return path.join(homedir(), '.dsh', 'ssh-helper')
41
+ }
42
+
43
+ /**
44
+ * Build one tagged error.
45
+ * @param {string} code - machine-readable code.
46
+ * @param {string} message - Chinese message.
47
+ * @param {unknown} [cause] - underlying failure.
48
+ * @returns {Error & { code: string }} tagged error.
49
+ */
50
+ function taggedError(code, message, cause) {
51
+ const error = new Error(message)
52
+ error.code = code
53
+ if (cause !== undefined) error.cause = cause
54
+ return error
55
+ }
56
+
57
+ /** @returns {Error} invalid-input error. */
58
+ function invalid(message) {
59
+ return taggedError('invalid-input', message)
60
+ }
61
+
62
+ /** @returns {Error} conflict error. */
63
+ function conflict(message) {
64
+ return taggedError('conflict', message)
65
+ }
66
+
67
+ /** @returns {Error} not-found error. */
68
+ function notFound(message) {
69
+ return taggedError('not-found', message)
70
+ }
71
+
72
+ /** @returns {Error} storage error. */
73
+ function storageError(cause) {
74
+ return taggedError('storage', '无法保存账户数据', cause)
75
+ }
76
+
77
+ /** @param {unknown} value @returns {boolean} whether the value is a plain object. */
78
+ function isObject(value) {
79
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
80
+ }
81
+
82
+ /**
83
+ * Read one optional string field.
84
+ * @param {unknown} value - raw value.
85
+ * @param {string} label - field label for errors.
86
+ * @param {boolean} [allowEmpty] - whether an empty string is accepted.
87
+ * @returns {string | undefined} trimmed value or undefined.
88
+ */
89
+ function readString(value, label, allowEmpty = false) {
90
+ if (value === undefined || value === null) return undefined
91
+ if (typeof value !== 'string') throw invalid(`${label}格式无效`)
92
+ const trimmed = value.trim()
93
+ if (!allowEmpty && trimmed === '') return undefined
94
+ return trimmed
95
+ }
96
+
97
+ /**
98
+ * Normalize one port value.
99
+ * @param {unknown} value - raw port.
100
+ * @returns {number} integer port in 1..65535.
101
+ */
102
+ function readPort(value) {
103
+ const numeric = typeof value === 'string' && value.trim() !== '' ? Number(value) : value
104
+ if (typeof numeric !== 'number' || !Number.isFinite(numeric) || !Number.isInteger(numeric)) {
105
+ throw invalid('端口必须是 1-65535 之间的整数')
106
+ }
107
+ if (numeric < 1 || numeric > 65535) throw invalid('端口必须是 1-65535 之间的整数')
108
+ return numeric
109
+ }
110
+
111
+ /**
112
+ * Normalize an authentication type.
113
+ * @param {unknown} value - raw value.
114
+ * @returns {'password' | 'key'} normalized type.
115
+ */
116
+ function readAuthType(value) {
117
+ if (typeof value !== 'string' || !AUTH_TYPES.has(value)) throw invalid('认证方式必须是 password 或 key')
118
+ return value
119
+ }
120
+
121
+ /**
122
+ * Pick an incoming value, falling back to the stored record value.
123
+ * @param {object} draft - incoming draft.
124
+ * @param {string} key - field name.
125
+ * @param {object | undefined} existing - stored record.
126
+ * @returns {unknown} effective raw value.
127
+ */
128
+ function pick(draft, key, existing) {
129
+ const value = draft[key]
130
+ if (value !== undefined && value !== null) return value
131
+ if (existing === undefined) return undefined
132
+ return existing[key]
133
+ }
134
+
135
+ /**
136
+ * Resolve credentials from a draft.
137
+ *
138
+ * `undefined` or `''` keeps a stored credential of the same authentication
139
+ * type; a non-empty string replaces it. Changing `authType` requires a matching
140
+ * new credential because the old one belongs to the previous method.
141
+ *
142
+ * @param {object} draft - incoming draft.
143
+ * @param {object | undefined} existing - stored record when updating.
144
+ * @param {'password' | 'key'} authType - resolved authentication type.
145
+ * @returns {{ password?: string, privateKey?: string, passphrase?: string }} resolved secrets.
146
+ */
147
+ function resolveSecrets(draft, existing, authType) {
148
+ if (authType === 'password') {
149
+ const raw = draft.password
150
+ let password
151
+ if (raw === undefined || raw === '') password = existing && existing.authType === 'password' ? existing.password : undefined
152
+ else if (typeof raw === 'string') password = raw
153
+ else throw invalid('密码格式无效')
154
+ if (typeof password !== 'string' || password === '') {
155
+ if (existing && existing.authType !== 'password') throw invalid('切换认证方式需要提供新的密码')
156
+ throw invalid('密码认证需要填写密码')
157
+ }
158
+ return { password }
159
+ }
160
+
161
+ const rawKey = draft.privateKey
162
+ let privateKey
163
+ if (rawKey === undefined || rawKey === '') privateKey = existing && existing.authType === 'key' ? existing.privateKey : undefined
164
+ else if (typeof rawKey === 'string') privateKey = rawKey
165
+ else throw invalid('私钥格式无效')
166
+ if (typeof privateKey !== 'string' || privateKey === '') {
167
+ if (existing && existing.authType !== 'key') throw invalid('切换认证方式需要提供新的私钥')
168
+ throw invalid('私钥认证需要填写私钥')
169
+ }
170
+
171
+ /** @type {{ privateKey: string, passphrase?: string }} */
172
+ const resolved = { privateKey }
173
+ const rawPassphrase = draft.passphrase
174
+ if (rawPassphrase === undefined || rawPassphrase === '') {
175
+ if (existing && existing.authType === 'key' && typeof existing.passphrase === 'string' && existing.passphrase !== '') {
176
+ resolved.passphrase = existing.passphrase
177
+ }
178
+ } else if (typeof rawPassphrase === 'string') {
179
+ resolved.passphrase = rawPassphrase
180
+ } else {
181
+ throw invalid('私钥口令格式无效')
182
+ }
183
+ return resolved
184
+ }
185
+
186
+ /**
187
+ * Build a complete internal account record from a draft.
188
+ * @param {object} draft - create/update/test draft.
189
+ * @param {object | undefined} existing - stored record when updating.
190
+ * @returns {object} complete record (contains credentials).
191
+ */
192
+ function buildRecord(draft, existing) {
193
+ if (!isObject(draft)) throw invalid('请求内容无效')
194
+
195
+ const host = readString(pick(draft, 'host', existing), '主机地址')
196
+ if (host === undefined) throw invalid('主机地址不能为空')
197
+ if (/\s/.test(host)) throw invalid('主机地址不能包含空白字符')
198
+ if (host.length > 255) throw invalid('主机地址不能超过 255 个字符')
199
+
200
+ const username = readString(pick(draft, 'username', existing), '用户名')
201
+ if (username === undefined) throw invalid('用户名不能为空')
202
+ if (username.length > 128) throw invalid('用户名不能超过 128 个字符')
203
+
204
+ const rawPort = pick(draft, 'port', existing)
205
+ const port = readPort(rawPort === undefined || rawPort === null || rawPort === '' ? DEFAULT_PORT : rawPort)
206
+ const authType = readAuthType(pick(draft, 'authType', existing))
207
+
208
+ let name
209
+ if (draft.name === undefined || draft.name === null) {
210
+ name = existing && typeof existing.name === 'string' ? existing.name : undefined
211
+ } else if (typeof draft.name !== 'string') {
212
+ throw invalid('账户名称格式无效')
213
+ } else {
214
+ const trimmed = draft.name.trim()
215
+ name = trimmed === '' && existing && typeof existing.name === 'string' ? existing.name : trimmed
216
+ }
217
+ if (name === undefined || name === '') name = `${username}@${host}`
218
+ if (name.length > NAME_MAX_LENGTH) throw invalid(`账户名称最多 ${NAME_MAX_LENGTH} 个字符`)
219
+
220
+ let note
221
+ const rawNote = pick(draft, 'note', existing)
222
+ if (rawNote !== undefined) {
223
+ if (typeof rawNote !== 'string') throw invalid('备注格式无效')
224
+ note = rawNote.trim()
225
+ if (note.length > NOTE_MAX_LENGTH) throw invalid(`备注最多 ${NOTE_MAX_LENGTH} 个字符`)
226
+ }
227
+
228
+ const now = new Date().toISOString()
229
+ const record = {
230
+ id: existing && typeof existing.id === 'string' ? existing.id : randomUUID(),
231
+ name,
232
+ host,
233
+ port,
234
+ username,
235
+ authType,
236
+ ...resolveSecrets(draft, existing, authType),
237
+ createdAt: existing && typeof existing.createdAt === 'string' ? existing.createdAt : now,
238
+ updatedAt: now,
239
+ }
240
+ if (note !== undefined && note !== '') record.note = note
241
+ return record
242
+ }
243
+
244
+ /**
245
+ * Project an internal record to its public, secret-free view.
246
+ * @param {object | undefined} record - internal record.
247
+ * @returns {object | undefined} public view.
248
+ */
249
+ function toView(record) {
250
+ if (!isObject(record)) return undefined
251
+ const view = {
252
+ id: String(record.id ?? ''),
253
+ name: typeof record.name === 'string' ? record.name : '',
254
+ host: typeof record.host === 'string' ? record.host : '',
255
+ port: Number.isInteger(record.port) ? record.port : DEFAULT_PORT,
256
+ username: typeof record.username === 'string' ? record.username : '',
257
+ authType: record.authType === 'key' ? 'key' : 'password',
258
+ hasPassword: typeof record.password === 'string' && record.password !== '',
259
+ hasKey: typeof record.privateKey === 'string' && record.privateKey !== '',
260
+ createdAt: typeof record.createdAt === 'string' ? record.createdAt : '',
261
+ updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : '',
262
+ }
263
+ if (typeof record.note === 'string' && record.note !== '') view.note = record.note
264
+ return view
265
+ }
266
+
267
+ /**
268
+ * Whether one parsed entry is a usable persisted account.
269
+ * @param {unknown} entry - parsed entry.
270
+ * @returns {boolean} validity.
271
+ */
272
+ function isPersistedRecord(entry) {
273
+ if (!isObject(entry)) return false
274
+ if (typeof entry.id !== 'string' || entry.id === '') return false
275
+ if (typeof entry.name !== 'string' || entry.name === '') return false
276
+ if (typeof entry.host !== 'string' || entry.host === '') return false
277
+ if (typeof entry.username !== 'string' || entry.username === '') return false
278
+ if (!AUTH_TYPES.has(entry.authType)) return false
279
+ if (!Number.isInteger(entry.port) || entry.port < 1 || entry.port > 65535) return false
280
+ return true
281
+ }
282
+
283
+ /**
284
+ * Keep only known fields of a persisted account so malformed extras never
285
+ * reach a public projection.
286
+ * @param {object} entry - parsed record.
287
+ * @returns {object} sanitized record.
288
+ */
289
+ function sanitizePersisted(entry) {
290
+ const record = {
291
+ id: entry.id,
292
+ name: entry.name,
293
+ host: entry.host,
294
+ port: entry.port,
295
+ username: entry.username,
296
+ authType: entry.authType,
297
+ createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date().toISOString(),
298
+ updatedAt: typeof entry.updatedAt === 'string' ? entry.updatedAt : new Date().toISOString(),
299
+ }
300
+ if (entry.authType === 'password' && typeof entry.password === 'string') record.password = entry.password
301
+ if (entry.authType === 'key' && typeof entry.privateKey === 'string') record.privateKey = entry.privateKey
302
+ if (entry.authType === 'key' && typeof entry.passphrase === 'string' && entry.passphrase !== '') record.passphrase = entry.passphrase
303
+ if (typeof entry.note === 'string' && entry.note !== '') record.note = entry.note
304
+ return record
305
+ }
306
+
307
+ /**
308
+ * The legacy ssh-manager data file locations, in priority order.
309
+ * @returns {string[]} candidate absolute paths.
310
+ */
311
+ function legacySources() {
312
+ const sources = []
313
+ const home = process.env.DSH_HOME
314
+ if (typeof home === 'string' && home.trim() !== '') sources.push(path.join(path.resolve(home.trim()), 'ssh-manager', 'servers.json'))
315
+ const profile = process.env.DSH_PROFILE_DIR
316
+ if (typeof profile === 'string' && profile.trim() !== '') sources.push(path.join(path.resolve(profile.trim()), 'data', 'ssh-manager', 'servers.json'))
317
+ return [...new Set(sources)]
318
+ }
319
+
320
+ /**
321
+ * Read the first readable legacy array, if any.
322
+ * @returns {{ source?: string, records: object[] }} legacy data.
323
+ */
324
+ function readLegacy() {
325
+ for (const source of legacySources()) {
326
+ let text
327
+ try {
328
+ text = readFileSync(source, 'utf8')
329
+ } catch {
330
+ continue
331
+ }
332
+ try {
333
+ const parsed = JSON.parse(text)
334
+ if (Array.isArray(parsed)) return { source, records: parsed }
335
+ } catch {
336
+ /* a corrupt legacy file is skipped; the plugin never logs its contents */
337
+ }
338
+ }
339
+ return { records: [] }
340
+ }
341
+
342
+ /**
343
+ * Whether one legacy entry has enough data to import.
344
+ * @param {unknown} entry - legacy record.
345
+ * @returns {boolean} usability.
346
+ */
347
+ function isLegacyUsable(entry) {
348
+ if (!isObject(entry)) return false
349
+ if (typeof entry.host !== 'string' || entry.host.trim() === '') return false
350
+ if (typeof entry.username !== 'string' || entry.username.trim() === '') return false
351
+ if (entry.authType !== 'password' && entry.authType !== 'key') return false
352
+ if (entry.authType === 'password' && typeof entry.password !== 'string') return false
353
+ if (entry.authType === 'key' && typeof entry.privateKey !== 'string') return false
354
+ return true
355
+ }
356
+
357
+ /**
358
+ * Stable duplicate key for import: `host:port:username`.
359
+ * @param {{ host?: unknown, port?: unknown, username?: unknown }} entry - record.
360
+ * @returns {string} key.
361
+ */
362
+ function serverKey(entry) {
363
+ const host = typeof entry.host === 'string' ? entry.host.trim().toLowerCase() : ''
364
+ const port = Number.isInteger(entry.port) ? entry.port : DEFAULT_PORT
365
+ const username = typeof entry.username === 'string' ? entry.username.trim().toLowerCase() : ''
366
+ return `${host}:${port}:${username}`
367
+ }
368
+
369
+ /**
370
+ * Create the account store.
371
+ * @param {{ dir?: string }} [options] - `dir` overrides the resolved data directory (tests).
372
+ * @returns {{
373
+ * dataDir: () => string,
374
+ * load: () => void,
375
+ * list: () => object[],
376
+ * get: (id: string) => object | undefined,
377
+ * find: (query: string) => object | undefined,
378
+ * getSecretRecord: (id: string) => object | undefined,
379
+ * create: (draft: object) => Promise<object>,
380
+ * update: (id: string, patch: object) => Promise<object>,
381
+ * remove: (id: string) => Promise<boolean>,
382
+ * testDraft: (draft: object) => Promise<object>,
383
+ * toView: (record: object) => object | undefined,
384
+ * legacyInfo: () => { available: boolean, count: number, source?: string },
385
+ * importLegacy: () => Promise<{ imported: number, skipped: number, total: number }>,
386
+ * }} store API.
387
+ */
388
+ export function createAccountStore({ dir } = {}) {
389
+ const resolvedDir = typeof dir === 'string' && dir.trim() !== '' ? path.resolve(dir) : resolveDataDir()
390
+ const file = createJsonFile({ dir: resolvedDir, filename: DATA_FILE })
391
+
392
+ /** @type {object[]} internal records (may contain credentials). */
393
+ let records = []
394
+ let loaded = false
395
+ /** @type {Promise<unknown>} tail of the serialized mutation chain. */
396
+ let mutationTail = Promise.resolve()
397
+
398
+ /** Read the data file once; never throws. */
399
+ function load() {
400
+ if (loaded) return
401
+ loaded = true
402
+ const parsed = file.read()
403
+ if (!Array.isArray(parsed)) {
404
+ records = []
405
+ return
406
+ }
407
+ records = parsed.filter(isPersistedRecord).map(sanitizePersisted)
408
+ }
409
+
410
+ /** Ensure the lazy read happened. */
411
+ function ensureLoaded() {
412
+ if (!loaded) load()
413
+ }
414
+
415
+ /**
416
+ * @param {string} id - record id.
417
+ * @returns {object | undefined} internal record.
418
+ */
419
+ function rawById(id) {
420
+ if (typeof id !== 'string' || id === '') return undefined
421
+ return records.find((record) => record.id === id)
422
+ }
423
+
424
+ /**
425
+ * Reject a display name used by another account (case-insensitive).
426
+ * @param {string} name - candidate name.
427
+ * @param {string} selfId - id of the record being written.
428
+ * @throws {Error} conflict when another account owns the name.
429
+ */
430
+ function assertNameAvailable(name, selfId) {
431
+ const wanted = name.toLowerCase()
432
+ const clash = records.find((record) => record.id !== selfId && typeof record.name === 'string' && record.name.toLowerCase() === wanted)
433
+ if (clash) throw conflict('账户名称已存在')
434
+ }
435
+
436
+ /**
437
+ * Serialize one mutation on top of earlier mutations.
438
+ * @template T
439
+ * @param {() => Promise<T>} task - mutation.
440
+ * @returns {Promise<T>} outcome.
441
+ */
442
+ function enqueue(task) {
443
+ const run = mutationTail.then(task, task)
444
+ mutationTail = run.then(
445
+ () => undefined,
446
+ () => undefined,
447
+ )
448
+ return run
449
+ }
450
+
451
+ /** @returns {string} resolved data directory. */
452
+ function dataDir() {
453
+ return resolvedDir
454
+ }
455
+
456
+ /** @returns {object[]} public views sorted by name. */
457
+ function list() {
458
+ ensureLoaded()
459
+ return records
460
+ .slice()
461
+ .sort((left, right) => String(left.name).localeCompare(String(right.name)))
462
+ .map((record) => toView(record))
463
+ }
464
+
465
+ /**
466
+ * @param {string} id - record id.
467
+ * @returns {object | undefined} public view.
468
+ */
469
+ function get(id) {
470
+ ensureLoaded()
471
+ return toView(rawById(id))
472
+ }
473
+
474
+ /**
475
+ * Resolve an account by id, exact name (case-insensitive), then a unique name
476
+ * prefix.
477
+ * @param {string} query - id or name text.
478
+ * @returns {object | undefined} public view.
479
+ */
480
+ function find(query) {
481
+ ensureLoaded()
482
+ if (typeof query !== 'string') return undefined
483
+ const text = query.trim()
484
+ if (text === '') return undefined
485
+ const byId = records.find((record) => record.id === text)
486
+ if (byId) return toView(byId)
487
+ const lower = text.toLowerCase()
488
+ const byName = records.find((record) => typeof record.name === 'string' && record.name.toLowerCase() === lower)
489
+ if (byName) return toView(byName)
490
+ const byPrefix = records.filter((record) => typeof record.name === 'string' && record.name.toLowerCase().startsWith(lower))
491
+ if (byPrefix.length === 1) return toView(byPrefix[0])
492
+ return undefined
493
+ }
494
+
495
+ /**
496
+ * Host-only access to the full record including credentials.
497
+ * @param {string} id - record id.
498
+ * @returns {object | undefined} shallow copy of the internal record.
499
+ */
500
+ function getSecretRecord(id) {
501
+ ensureLoaded()
502
+ const record = rawById(id)
503
+ return record === undefined ? undefined : { ...record }
504
+ }
505
+
506
+ /**
507
+ * Create one account.
508
+ * @param {object} draft - create payload.
509
+ * @returns {Promise<object>} public view.
510
+ */
511
+ function create(draft) {
512
+ ensureLoaded()
513
+ return enqueue(async () => {
514
+ const record = buildRecord(draft, undefined)
515
+ assertNameAvailable(record.name, record.id)
516
+ const previous = records
517
+ records = [...records, record]
518
+ try {
519
+ await file.write(records)
520
+ } catch (error) {
521
+ records = previous
522
+ throw storageError(error)
523
+ }
524
+ return toView(record)
525
+ })
526
+ }
527
+
528
+ /**
529
+ * Update one account. `undefined`/`''` secrets keep the stored credential.
530
+ * @param {string} id - record id.
531
+ * @param {object} patch - update payload.
532
+ * @returns {Promise<object>} public view.
533
+ */
534
+ function update(id, patch) {
535
+ ensureLoaded()
536
+ return enqueue(async () => {
537
+ const existing = rawById(id)
538
+ if (!existing) throw notFound('账户不存在')
539
+ const updated = buildRecord(isObject(patch) ? patch : {}, existing)
540
+ assertNameAvailable(updated.name, existing.id)
541
+ const previous = records
542
+ records = records.map((record) => (record.id === id ? updated : record))
543
+ try {
544
+ await file.write(records)
545
+ } catch (error) {
546
+ records = previous
547
+ throw storageError(error)
548
+ }
549
+ return toView(updated)
550
+ })
551
+ }
552
+
553
+ /**
554
+ * Remove one account.
555
+ * @param {string} id - record id.
556
+ * @returns {Promise<boolean>} whether a record was removed.
557
+ */
558
+ function remove(id) {
559
+ ensureLoaded()
560
+ return enqueue(async () => {
561
+ if (!rawById(id)) return false
562
+ const previous = records
563
+ records = records.filter((record) => record.id !== id)
564
+ try {
565
+ await file.write(records)
566
+ } catch (error) {
567
+ records = previous
568
+ throw storageError(error)
569
+ }
570
+ return true
571
+ })
572
+ }
573
+
574
+ /**
575
+ * Validate a credential test draft without persisting it. When `draft.id`
576
+ * points at a stored account, missing secrets come from that account.
577
+ * @param {object} draft - draft payload.
578
+ * @returns {Promise<object>} complete record (contains credentials; host-only).
579
+ */
580
+ async function testDraft(draft) {
581
+ ensureLoaded()
582
+ if (!isObject(draft)) throw invalid('请求内容无效')
583
+ let existing
584
+ if (draft.id !== undefined && draft.id !== null && draft.id !== '') {
585
+ if (typeof draft.id !== 'string') throw invalid('账户 id 格式无效')
586
+ existing = rawById(draft.id)
587
+ if (!existing) throw notFound('账户不存在')
588
+ }
589
+ return buildRecord(draft, existing)
590
+ }
591
+
592
+ /**
593
+ * Report whether the legacy ssh-manager data file is importable.
594
+ * @returns {{ available: boolean, count: number, source?: string }} legacy info.
595
+ */
596
+ function legacyInfo() {
597
+ const legacy = readLegacy()
598
+ if (legacy.source === undefined) return { available: false, count: 0 }
599
+ return { available: legacy.records.length > 0, count: legacy.records.length, source: legacy.source }
600
+ }
601
+
602
+ /**
603
+ * Import legacy accounts, skipping entries whose `host:port:username` already
604
+ * exists. Never logs credentials.
605
+ * @returns {Promise<{ imported: number, skipped: number, total: number }>} import summary.
606
+ */
607
+ function importLegacy() {
608
+ ensureLoaded()
609
+ return enqueue(async () => {
610
+ const legacy = readLegacy()
611
+ const total = legacy.records.length
612
+ if (total === 0) return { imported: 0, skipped: 0, total: 0 }
613
+
614
+ const seenServers = new Set(records.map((record) => serverKey(record)))
615
+ const takenNames = new Set(records.map((record) => String(record.name).toLowerCase()))
616
+ const additions = []
617
+ let skipped = 0
618
+
619
+ for (const entry of legacy.records) {
620
+ if (!isLegacyUsable(entry)) {
621
+ skipped += 1
622
+ continue
623
+ }
624
+ const key = serverKey(entry)
625
+ if (seenServers.has(key)) {
626
+ skipped += 1
627
+ continue
628
+ }
629
+ try {
630
+ const record = buildRecord(
631
+ {
632
+ name: entry.name,
633
+ host: entry.host,
634
+ port: entry.port === undefined ? DEFAULT_PORT : entry.port,
635
+ username: entry.username,
636
+ authType: entry.authType,
637
+ password: entry.password,
638
+ privateKey: entry.privateKey,
639
+ passphrase: entry.passphrase,
640
+ note: entry.note,
641
+ },
642
+ undefined,
643
+ )
644
+ let name = record.name
645
+ let suffix = 2
646
+ while (takenNames.has(name.toLowerCase())) {
647
+ name = `${record.name} (${suffix})`
648
+ suffix += 1
649
+ }
650
+ record.name = name
651
+ takenNames.add(name.toLowerCase())
652
+ seenServers.add(key)
653
+ additions.push(record)
654
+ } catch {
655
+ skipped += 1
656
+ }
657
+ }
658
+
659
+ if (additions.length > 0) {
660
+ const previous = records
661
+ records = [...records, ...additions]
662
+ try {
663
+ await file.write(records)
664
+ } catch (error) {
665
+ records = previous
666
+ throw storageError(error)
667
+ }
668
+ }
669
+ return { imported: additions.length, skipped, total }
670
+ })
671
+ }
672
+
673
+ return {
674
+ dataDir,
675
+ load,
676
+ list,
677
+ get,
678
+ find,
679
+ getSecretRecord,
680
+ create,
681
+ update,
682
+ remove,
683
+ testDraft,
684
+ toView,
685
+ legacyInfo,
686
+ importLegacy,
687
+ }
688
+ }