dsh-ssh-manage 0.3.5 → 0.3.7

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/README.en.md CHANGED
@@ -22,8 +22,9 @@
22
22
 
23
23
  `dsh-ssh-manage` is a Web plugin for [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness). It turns the hosts in `~/.ssh/config` into manageable objects in the DSH sidebar:
24
24
 
25
- - **Host management** — scan the config, groups, tags, favorites, notes, latency and host inspection;
25
+ - **Host management** — scan the config, groups, tags, favorites, notes, latency and host inspection; SSH aliases are globally unique, so use the repeatable Display name for same names across groups;
26
26
  - **Interactive terminal** — ssh2 allocates a PTY, xterm.js renders it, with colors, arrow keys, full-screen programs such as `vim`/`top`, and window resizing;
27
+ - **Backup and migration** — export/import hosts, groups, tags, notes, favorites and passwords from Settings; exports are re-encrypted with a passphrase (scrypt + AES-256-GCM) and imports merge by alias;
27
28
  - **Day-to-day operations** — batch commands, SFTP browse/mkdir/rename/delete/upload/download, connection tests;
28
29
  - **Credentials** — one AES-256-GCM encrypted password per host, never written to `~/.ssh/config`; passwords are not returned to the browser by default, but Show password decrypts one on demand in the current page and auto-hides it after 30 seconds;
29
30
  - **Agent tool** — `ssh_manager` exposes the same capabilities to the model.
@@ -96,16 +97,16 @@ Creating a host requires alias, hostname and password. The password is not writt
96
97
 
97
98
  ### From npm (recommended)
98
99
 
99
- npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.5`).
100
+ npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.3.7`).
100
101
 
101
102
  ```text
102
- plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.5
103
+ plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.7
103
104
  ```
104
105
 
105
106
  Or with the DSH CLI:
106
107
 
107
108
  ```bash
108
- dsh plugin add dsh-ssh-manage@0.3.5
109
+ dsh plugin add dsh-ssh-manage@0.3.7
109
110
  ```
110
111
 
111
112
  After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
@@ -221,6 +222,7 @@ The terminal uses vendored xterm.js 6.0.0 (MIT, see `assets/LICENSE.xterm`), @xt
221
222
  - **Config editing scope**: the editor only changes `Host`, `HostName`, `User`, `Port` and plugin metadata; maintain other SSH directives in `~/.ssh/config`.
222
223
  - **Batch commands** run one command; they are not an interactive terminal. Per-host output is capped at 1 MB and marked `truncated` when exceeded.
223
224
  - **File size**: SFTP files are capped at 10 MB.
225
+ - **Export safety**: encrypted exports contain all passwords; a lost passphrase is unrecoverable. Import only merges and never deletes existing hosts or passwords.
224
226
  - **SFTP rename/delete**: rename refuses to overwrite an existing destination by default (`sftp_rename` can pass `overwrite: true` explicitly); deleting a directory requires it to be empty, and root paths, `.`/`..` and drive roots are refused; symlinks are removed as links, not followed.
225
227
  - **xterm assets**: the first terminal open loads about 500 KB of xterm.js basics; the search addon loads on demand, about 556 KiB total from the same origin (no CDN).
226
228
 
package/README.md CHANGED
@@ -22,8 +22,9 @@
22
22
 
23
23
  `dsh-ssh-manage` 是 [DeepSeek Harness (DSH)](https://github.com/deepseek-ai/deepseek-harness) 的 Web 插件。它把 `~/.ssh/config` 里的主机变成 DSH 侧边栏里可管理、可连接、可运维的对象:
24
24
 
25
- - **主机管理**:扫描配置、分组、标签、收藏、备注、连接延迟、主机巡检;
25
+ - **主机管理**:扫描配置、分组、标签、收藏、备注、连接延迟、主机巡检;SSH 别名全局唯一,不同分组如需同名请使用可重复的“显示名称”;
26
26
  - **交互式终端**:ssh2 分配 PTY,xterm.js 渲染,支持彩色输出、方向键、`vim`/`top` 等全屏程序和窗口自适应;
27
+ - **备份迁移**:设置页可导出/导入主机、分组、标签、备注、收藏和密码;导出文件用自定义口令二次加密(scrypt + AES-256-GCM),导入按别名合并;
27
28
  - **日常运维**:批量命令、SFTP 目录浏览 / 新建 / 重命名 / 删除 / 上传 / 下载、测试连接;
28
29
  - **凭据管理**:每台主机保存一份 AES-256-GCM 加密密码,不写进 `~/.ssh/config`;默认不返回浏览器,在主机详情点“显示密码”时才按需解密到当前页面,30 秒后自动隐藏;
29
30
  - **Agent 工具**:`ssh_manager` 把同一套能力开放给模型。
@@ -98,16 +99,16 @@
98
99
 
99
100
  ### 从 npm 安装(推荐)
100
101
 
101
- npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.5`)。
102
+ npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.3.7`)。
102
103
 
103
104
  ```text
104
- plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.5
105
+ plugin_manager action=install_bundle target=dsh-ssh-manage@0.3.7
105
106
  ```
106
107
 
107
108
  或使用 DSH CLI:
108
109
 
109
110
  ```bash
110
- dsh plugin add dsh-ssh-manage@0.3.5
111
+ dsh plugin add dsh-ssh-manage@0.3.7
111
112
  ```
112
113
 
113
114
  安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
package/client.js CHANGED
@@ -126,7 +126,7 @@ window.__ModuleLoader__.load({
126
126
  'editor.editTitle': ['编辑 SSH 主机', 'Edit SSH host'],
127
127
  'editor.alias': ['别名', 'Alias'],
128
128
  'editor.aliasPlaceholder': ['prod', 'prod'],
129
- 'editor.aliasHint': ['~/.ssh/config 中的 Host 名称,作为主机的唯一标识。', 'Host name in ~/.ssh/config; the unique identifier.'],
129
+ 'editor.aliasHint': ['~/.ssh/config 中的 Host 别名,必须全局唯一;不同分组要显示同名,请使用“显示名称”。', 'The Host alias in ~/.ssh/config must be globally unique. To show the same name in different groups, use Display name.'],
130
130
  'editor.hostname': ['服务器地址', 'Hostname'],
131
131
  'editor.hostnamePlaceholder': ['203.0.113.10 或 example.com', '203.0.113.10 or example.com'],
132
132
  'editor.user': ['用户', 'User'],
@@ -141,6 +141,9 @@ window.__ModuleLoader__.load({
141
141
  'editor.tagsPlaceholder': ['web, critical(逗号分隔)', 'web, critical (comma separated)'],
142
142
  'editor.label': ['显示名称', 'Display name'],
143
143
  'editor.labelPlaceholder': ['例如 生产 API', 'e.g. Production API'],
144
+ 'editor.labelHint': ['显示名称可以重复,列表和终端标签优先显示它;SSH 别名仍需唯一。', 'Display names may repeat; lists and terminal tabs prefer it. The SSH alias must still be unique.'],
145
+ 'editor.aliasExists': ['SSH 别名“{alias}”已被主机“{host}”使用(分组:{group})。SSH 要求别名全局唯一;如果只是想在另一个分组显示同名,请改用建议别名,并把“显示名称”设为 {alias}。', 'SSH alias "{alias}" is already used by "{host}" (group: {group}). SSH aliases must be globally unique. To show the same name in another group, use the suggested alias and set Display name to {alias}.'],
146
+ 'editor.useSuggestedAlias': ['使用建议别名 {alias}', 'Use suggested alias {alias}'],
144
147
  'editor.note': ['备注', 'Notes'],
145
148
  'editor.notePlaceholder': ['给这台主机的说明,会显示在列表里', 'A short note shown in the list'],
146
149
  'editor.passwordSection': ['服务器密码', 'Server password'],
@@ -261,6 +264,32 @@ window.__ModuleLoader__.load({
261
264
  'settings.stateDir': ['状态目录', 'State directory'],
262
265
  'settings.vault': ['凭据仓库', 'Credential vault'],
263
266
  'settings.vaultDesc': ['密码使用 AES-256-GCM 加密保存在本机状态目录,插件启动时自动解锁,不需要主密码。', 'Passwords are AES-256-GCM encrypted in the local state directory and unlocked automatically; no master password.'],
267
+ 'settings.transfer': ['备份与迁移', 'Backup and migration'],
268
+ 'settings.transferBadge': ['加密导出', 'Encrypted export'],
269
+ 'settings.transferDesc': ['导出主机、分组、标签、备注、收藏和密码,密码用你设置的口令二次加密;导入时按别名合并,不会删除现有主机。', 'Export hosts, groups, tags, notes, favorites and passwords. Passwords are encrypted again with a passphrase you choose; import merges by alias and never deletes existing hosts.'],
270
+ 'settings.export': ['导出', 'Export'],
271
+ 'settings.import': ['导入', 'Import'],
272
+ 'transfer.exportTitle': ['导出主机数据', 'Export host data'],
273
+ 'transfer.importTitle': ['导入主机数据', 'Import host data'],
274
+ 'transfer.exportDesc': ['将导出 {count} 台主机及其分组、标签、备注、收藏和全部密码。密码部分会用口令再次加密。', 'Exports {count} host(s) with groups, tags, notes, favorites and all passwords. Password data is encrypted again with a passphrase.'],
275
+ 'transfer.importDesc': ['选择由 dsh-ssh-manage 导出的 JSON 文件,输入导出时设置的口令。按主机别名合并:相同别名更新,新别名创建,不删除现有主机。', 'Choose a JSON file exported by dsh-ssh-manage and enter its passphrase. Hosts merge by alias: existing aliases update, new ones are created, nothing is deleted.'],
276
+ 'transfer.passphrase': ['导出/导入口令(至少 8 位)', 'Export/import passphrase (at least 8 characters)'],
277
+ 'transfer.confirmPassphrase': ['再次输入口令', 'Repeat the passphrase'],
278
+ 'transfer.passphraseHint': ['口令只用于加密导出文件,不会保存。忘记口令将无法恢复导出的密码。', 'The passphrase only encrypts the export file and is not stored. A lost passphrase cannot be recovered.'],
279
+ 'transfer.chooseFile': ['选择导出 JSON 文件', 'Choose export JSON file'],
280
+ 'transfer.fileReady': ['已选择:{count} 台主机', 'Selected: {count} host(s)'],
281
+ 'transfer.fileTooLarge': ['文件超过 8 MB,拒绝读取。', 'File is larger than 8 MB and was rejected.'],
282
+ 'transfer.invalidFile': ['不是有效的 dsh-ssh-manage 导出文件。', 'Not a valid dsh-ssh-manage export file.'],
283
+ 'transfer.chooseFileFirst': ['请先选择导出文件。', 'Choose an export file first.'],
284
+ 'transfer.passphraseShort': ['口令至少需要 8 位。', 'Passphrase must be at least 8 characters.'],
285
+ 'transfer.passphraseMismatch': ['两次输入的口令不一致。', 'The passphrases do not match.'],
286
+ 'transfer.exportWarning': ['导出文件包含加密后的密码,请妥善保管文件与口令,不要上传到公共位置。', 'The export contains encrypted passwords; keep both the file and passphrase safe and do not upload them publicly.'],
287
+ 'transfer.importMergeNote': ['导入采用合并模式,不会删除现有主机或密码。', 'Import uses merge mode and never deletes existing hosts or passwords.'],
288
+ 'transfer.doExport': ['加密导出', 'Export encrypted'],
289
+ 'transfer.doImport': ['导入并合并', 'Import and merge'],
290
+ 'transfer.exported': ['已导出 {count} 台主机', 'Exported {count} host(s)'],
291
+ 'transfer.imported': ['导入完成:新建 {created},更新 {updated},恢复密码 {passwords}', 'Import complete: {created} created, {updated} updated, {passwords} password(s) restored'],
292
+ 'transfer.importWarnings': ['{count} 条记录未能导入,请查看提示。', '{count} record(s) could not be imported.'],
264
293
  'settings.vaultCount': ['{count} 台主机已保存密码', '{count} hosts have a stored password'],
265
294
  'settings.vaultNone': ['还没有保存任何密码', 'No passwords stored yet'],
266
295
  'settings.appearance': ['外观', 'Appearance'],
@@ -479,6 +508,16 @@ window.__ModuleLoader__.load({
479
508
  setTimeout(() => URL.revokeObjectURL(url), 1500)
480
509
  }
481
510
 
511
+ function downloadTextFile(name, text, type = 'application/json') {
512
+ const blob = new Blob([text], { type })
513
+ const url = URL.createObjectURL(blob)
514
+ const anchor = document.createElement('a')
515
+ anchor.href = url
516
+ anchor.download = name || 'dsh-ssh-manage-export.json'
517
+ anchor.click()
518
+ setTimeout(() => URL.revokeObjectURL(url), 1500)
519
+ }
520
+
482
521
  function readFileBase64(file) {
483
522
  return new Promise((resolve, reject) => {
484
523
  const reader = new FileReader()
@@ -4693,6 +4732,7 @@ window.__ModuleLoader__.load({
4693
4732
  const [password, setPassword] = React.useState('')
4694
4733
  const [passwordError, setPasswordError] = React.useState('')
4695
4734
  const [formError, setFormError] = React.useState('')
4735
+ const [aliasSuggestion, setAliasSuggestion] = React.useState('')
4696
4736
  const [groupMenuOpen, setGroupMenuOpen] = React.useState(false)
4697
4737
  const groupMenuRef = React.useRef(null)
4698
4738
  const groupOptions = (model.allGroups ?? []).filter((group) => group.key !== '').map((group) => group.key)
@@ -4762,6 +4802,28 @@ window.__ModuleLoader__.load({
4762
4802
  setFormError('')
4763
4803
  return
4764
4804
  }
4805
+ const collision = hosts.find((host) =>
4806
+ host.host !== state.original
4807
+ && (host.host === alias || (Array.isArray(host.aliases) && host.aliases.includes(alias))),
4808
+ )
4809
+ if (collision) {
4810
+ const groupPart = draft.group.trim().replace(/[^A-Za-z0-9_.@-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 20)
4811
+ let suggested = groupPart ? `${alias}-${groupPart}` : `${alias}-2`
4812
+ let counter = 2
4813
+ while (hosts.some((host) => host.host === suggested || (Array.isArray(host.aliases) && host.aliases.includes(suggested)))) {
4814
+ suggested = `${alias}-${counter}`
4815
+ counter += 1
4816
+ }
4817
+ suggested = suggested.slice(0, 64)
4818
+ setAliasSuggestion(suggested)
4819
+ setFormError(t('editor.aliasExists', {
4820
+ alias,
4821
+ host: hostName(collision),
4822
+ group: collision.group || t('tree.ungrouped'),
4823
+ }))
4824
+ return
4825
+ }
4826
+ setAliasSuggestion('')
4765
4827
  setPasswordError('')
4766
4828
  setFormError('')
4767
4829
  const saved = await saveEditor({
@@ -4824,13 +4886,36 @@ window.__ModuleLoader__.load({
4824
4886
  h(
4825
4887
  'div',
4826
4888
  { className: 'ssh-formSection' },
4827
- formError ? h('div', { className: 'ssh-formError' }, h(IconAlert, { size: 13 }), h('span', null, formError)) : null,
4889
+ formError
4890
+ ? h(
4891
+ 'div',
4892
+ { className: 'ssh-formError' },
4893
+ h(IconAlert, { size: 13 }),
4894
+ h('span', null, formError),
4895
+ aliasSuggestion
4896
+ ? h(
4897
+ Button,
4898
+ {
4899
+ size: 'sm',
4900
+ variant: 'ghost',
4901
+ onClick: () => {
4902
+ if (!draft.label.trim()) update('label', alias)
4903
+ update('host', aliasSuggestion)
4904
+ setAliasSuggestion('')
4905
+ setFormError('')
4906
+ },
4907
+ },
4908
+ t('editor.useSuggestedAlias', { alias: aliasSuggestion }),
4909
+ )
4910
+ : null,
4911
+ )
4912
+ : null,
4828
4913
  h(
4829
4914
  'div',
4830
4915
  { className: 'ssh-formGrid' },
4831
4916
  h(
4832
4917
  Field,
4833
- { label: t('editor.alias') },
4918
+ { label: t('editor.alias'), hint: t('editor.aliasHint') },
4834
4919
  h(TextInput, {
4835
4920
  value: draft.host,
4836
4921
  onChange: (value) => update('host', value),
@@ -4928,7 +5013,7 @@ window.__ModuleLoader__.load({
4928
5013
  ),
4929
5014
  h(
4930
5015
  Field,
4931
- { label: t('editor.label') },
5016
+ { label: t('editor.label'), hint: t('editor.labelHint') },
4932
5017
  h(TextInput, {
4933
5018
  value: draft.label,
4934
5019
  onChange: (value) => update('label', value),
@@ -5287,8 +5372,201 @@ window.__ModuleLoader__.load({
5287
5372
  )
5288
5373
  }
5289
5374
 
5375
+ function TransferDialog({ model, mode, onClose }) {
5376
+ const { t, notify, load, hosts } = model
5377
+ const [passphrase, setPassphrase] = React.useState('')
5378
+ const [confirmation, setConfirmation] = React.useState('')
5379
+ const [file, setFile] = React.useState(null)
5380
+ const [reading, setReading] = React.useState(false)
5381
+ const [busy, setBusy] = React.useState(false)
5382
+ const [error, setError] = React.useState('')
5383
+ const busyRef = React.useRef(false)
5384
+ const fileRef = React.useRef(null)
5385
+
5386
+ React.useEffect(() => {
5387
+ const onKey = (event) => {
5388
+ if (event.key !== 'Escape' || busyRef.current) return
5389
+ event.preventDefault()
5390
+ event.stopPropagation()
5391
+ onClose()
5392
+ }
5393
+ document.addEventListener('keydown', onKey, true)
5394
+ return () => document.removeEventListener('keydown', onKey, true)
5395
+ }, [onClose])
5396
+
5397
+ async function pickFile(event) {
5398
+ const picked = event.target.files?.[0]
5399
+ event.target.value = ''
5400
+ if (!picked) return
5401
+ if (picked.size > 8 * 1024 * 1024) {
5402
+ setError(t('transfer.fileTooLarge'))
5403
+ setFile(null)
5404
+ return
5405
+ }
5406
+ setReading(true)
5407
+ setError('')
5408
+ try {
5409
+ const text = typeof picked.text === 'function'
5410
+ ? await picked.text()
5411
+ : await new Promise((resolve, reject) => {
5412
+ const reader = new FileReader()
5413
+ reader.onload = () => resolve(String(reader.result ?? ''))
5414
+ reader.onerror = () => reject(reader.error ?? new Error('Cannot read file'))
5415
+ reader.readAsText(picked)
5416
+ })
5417
+ const parsed = JSON.parse(text)
5418
+ if (!parsed || parsed.format !== 'dsh-ssh-manage-export' || !Array.isArray(parsed.hosts)) {
5419
+ throw new Error(t('transfer.invalidFile'))
5420
+ }
5421
+ setFile({ name: picked.name, data: parsed })
5422
+ } catch (failure) {
5423
+ setFile(null)
5424
+ setError(errorMessage(failure))
5425
+ } finally {
5426
+ setReading(false)
5427
+ }
5428
+ }
5429
+
5430
+ async function submit() {
5431
+ if (busyRef.current) return
5432
+ if (passphrase.length < 8) {
5433
+ setError(t('transfer.passphraseShort'))
5434
+ return
5435
+ }
5436
+ if (mode === 'export' && passphrase !== confirmation) {
5437
+ setError(t('transfer.passphraseMismatch'))
5438
+ return
5439
+ }
5440
+ if (mode === 'import' && !file) {
5441
+ setError(t('transfer.chooseFileFirst'))
5442
+ return
5443
+ }
5444
+ busyRef.current = true
5445
+ setBusy(true)
5446
+ setError('')
5447
+ try {
5448
+ if (mode === 'export') {
5449
+ const value = await callAction('data/export', { passphrase })
5450
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)
5451
+ downloadTextFile(`dsh-ssh-manage-export-${stamp}.json`, JSON.stringify(value, null, 2))
5452
+ notify(t('transfer.exported', { count: value?.hosts?.length ?? hosts.length }), 'success')
5453
+ } else {
5454
+ const value = await callAction('data/import', { export: file.data, passphrase })
5455
+ const summary = t('transfer.imported', {
5456
+ created: value?.created ?? 0,
5457
+ updated: value?.updated ?? 0,
5458
+ passwords: value?.passwords ?? 0,
5459
+ })
5460
+ if (Array.isArray(value?.errors) && value.errors.length > 0) {
5461
+ notify(`${summary};${t('transfer.importWarnings', { count: value.errors.length })}`, 'error')
5462
+ } else {
5463
+ notify(summary, 'success')
5464
+ }
5465
+ await load({ quiet: true })
5466
+ }
5467
+ onClose()
5468
+ } catch (failure) {
5469
+ setError(errorMessage(failure))
5470
+ } finally {
5471
+ busyRef.current = false
5472
+ setBusy(false)
5473
+ }
5474
+ }
5475
+
5476
+ return h(
5477
+ 'div',
5478
+ {
5479
+ className: 'ssh-fileDialogScrim',
5480
+ onMouseDown: () => {
5481
+ if (!busyRef.current) onClose()
5482
+ },
5483
+ },
5484
+ h(
5485
+ 'div',
5486
+ {
5487
+ className: 'ssh-fileDialog ssh-transferDialog',
5488
+ role: 'dialog',
5489
+ 'aria-modal': 'true',
5490
+ 'aria-label': mode === 'export' ? t('transfer.exportTitle') : t('transfer.importTitle'),
5491
+ onMouseDown: (event) => event.stopPropagation(),
5492
+ },
5493
+ h('div', { className: 'ssh-fileDialogTitle' }, mode === 'export' ? t('transfer.exportTitle') : t('transfer.importTitle')),
5494
+ h(
5495
+ 'div',
5496
+ { className: 'ssh-fileDialogBody' },
5497
+ mode === 'export'
5498
+ ? t('transfer.exportDesc', { count: hosts.length })
5499
+ : t('transfer.importDesc'),
5500
+ ),
5501
+ mode === 'import'
5502
+ ? h(
5503
+ 'div',
5504
+ { className: 'ssh-transferFile' },
5505
+ h('input', {
5506
+ ref: fileRef,
5507
+ type: 'file',
5508
+ accept: '.json,application/json',
5509
+ className: 'ssh-srOnly',
5510
+ onChange: (event) => void pickFile(event),
5511
+ }),
5512
+ h(
5513
+ Button,
5514
+ { size: 'sm', variant: 'ghost', onClick: () => fileRef.current?.click(), disabled: reading || busy },
5515
+ reading ? h(Spinner, { size: 12 }) : h(IconUpload, { size: 13 }),
5516
+ file ? file.name : t('transfer.chooseFile'),
5517
+ ),
5518
+ file ? h(Badge, { tone: 'ok' }, t('transfer.fileReady', { count: file.data.hosts.length })) : null,
5519
+ )
5520
+ : null,
5521
+ h(TextInput, {
5522
+ type: 'password',
5523
+ value: passphrase,
5524
+ onChange: setPassphrase,
5525
+ placeholder: t('transfer.passphrase'),
5526
+ autoFocus: true,
5527
+ disabled: busy,
5528
+ onKeyDown: (event) => {
5529
+ if (event.key === 'Enter') void submit()
5530
+ },
5531
+ }),
5532
+ mode === 'export'
5533
+ ? h(TextInput, {
5534
+ type: 'password',
5535
+ value: confirmation,
5536
+ onChange: setConfirmation,
5537
+ placeholder: t('transfer.confirmPassphrase'),
5538
+ disabled: busy,
5539
+ onKeyDown: (event) => {
5540
+ if (event.key === 'Enter') void submit()
5541
+ },
5542
+ })
5543
+ : null,
5544
+ h('div', { className: 'ssh-sectionHint' }, t('transfer.passphraseHint')),
5545
+ error ? h('div', { className: 'ssh-formError' }, h(IconAlert, { size: 13 }), h('span', null, error)) : null,
5546
+ h(
5547
+ 'div',
5548
+ { className: 'ssh-sectionHint ssh-revealWarning' },
5549
+ h(IconAlert, { size: 12 }),
5550
+ h('span', null, mode === 'export' ? t('transfer.exportWarning') : t('transfer.importMergeNote')),
5551
+ ),
5552
+ h(
5553
+ 'div',
5554
+ { className: 'ssh-fileDialogActions' },
5555
+ h(Button, { variant: 'ghost', size: 'sm', onClick: onClose, disabled: busy }, t('common.cancel')),
5556
+ h(
5557
+ Button,
5558
+ { variant: 'primary', size: 'sm', onClick: () => void submit(), disabled: busy || reading },
5559
+ busy ? h(Spinner, { size: 12 }) : null,
5560
+ mode === 'export' ? t('transfer.doExport') : t('transfer.doImport'),
5561
+ ),
5562
+ ),
5563
+ ),
5564
+ )
5565
+ }
5566
+
5290
5567
  function SettingsTab({ model }) {
5291
5568
  const { t, snapshot, hosts, columns, setColumns, copyValue, load, refreshing } = model
5569
+ const [transferMode, setTransferMode] = React.useState(null)
5292
5570
  const passwordCount = hosts.filter((host) => host.passwordConfigured).length
5293
5571
  const exists = snapshot?.exists === true
5294
5572
  return h(
@@ -5359,6 +5637,33 @@ window.__ModuleLoader__.load({
5359
5637
  h('span', null, `${passwordCount} / ${hosts.length}`),
5360
5638
  ),
5361
5639
  ),
5640
+ h(
5641
+ 'section',
5642
+ { className: 'ssh-card' },
5643
+ h(
5644
+ 'div',
5645
+ { className: 'ssh-cardHead' },
5646
+ h('h3', null, t('settings.transfer')),
5647
+ h(Badge, { tone: 'muted' }, t('settings.transferBadge')),
5648
+ ),
5649
+ h('p', { className: 'ssh-cardDesc' }, t('settings.transferDesc')),
5650
+ h(
5651
+ 'div',
5652
+ { className: 'ssh-cardActions' },
5653
+ h(
5654
+ Button,
5655
+ { size: 'sm', variant: 'ghost', onClick: () => setTransferMode('export') },
5656
+ h(IconDownload, { size: 13 }),
5657
+ t('settings.export'),
5658
+ ),
5659
+ h(
5660
+ Button,
5661
+ { size: 'sm', variant: 'ghost', onClick: () => setTransferMode('import') },
5662
+ h(IconUpload, { size: 13 }),
5663
+ t('settings.import'),
5664
+ ),
5665
+ ),
5666
+ ),
5362
5667
  h(
5363
5668
  'section',
5364
5669
  { className: 'ssh-card' },
@@ -5408,6 +5713,13 @@ window.__ModuleLoader__.load({
5408
5713
  h('span', null, t('settings.aboutTag')),
5409
5714
  ),
5410
5715
  ),
5716
+ transferMode
5717
+ ? h(TransferDialog, {
5718
+ model,
5719
+ mode: transferMode,
5720
+ onClose: () => setTransferMode(null),
5721
+ })
5722
+ : null,
5411
5723
  ),
5412
5724
  )
5413
5725
  }
@@ -5705,6 +6017,8 @@ body.ssh-col-resizing,body.ssh-col-resizing *{cursor:col-resize!important;user-s
5705
6017
  .ssh-fileRow{display:grid;grid-template-columns:22px minmax(0,1fr) 90px auto;align-items:center;gap:8px;padding:0 10px;height:34px;border-bottom:.5px solid var(--ssh-border);font-size:12px}
5706
6018
  .ssh-fileActions{display:flex;align-items:center;gap:2px}
5707
6019
  .ssh-fileDialogScrim{position:absolute;inset:0;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--dsw-alias-bg-mask-1,color-mix(in srgb,var(--ssh-text) 18%,transparent))}
6020
+ .ssh-transferDialog{width:min(460px,100%)}
6021
+ .ssh-transferFile{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
5708
6022
  .ssh-fileDialog{width:min(360px,100%);padding:14px;border:.5px solid var(--ssh-border-strong);border-radius:12px;background:var(--ssh-layer-1);box-shadow:var(--ssh-shadow);display:flex;flex-direction:column;gap:10px}
5709
6023
  .ssh-fileDialogTitle{font-size:13px;font-weight:600;color:var(--ssh-text)}
5710
6024
  .ssh-fileDialogBody{font-size:12px;line-height:1.5;color:var(--ssh-text-2)}
package/index.js CHANGED
@@ -6,6 +6,7 @@ import { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost } from './lib
6
6
  import { deleteMetadata, loadMetadata, updateMetadata } from './lib/metadata.js'
7
7
  import { batchExec, deleteRemotePath, downloadFile, execCommand, inspectHost, listRemoteFiles, makeRemoteDirectory, openPty, renameRemotePath, testConnection, uploadFile } from './lib/ssh-client.js'
8
8
  import { storagePaths } from './lib/storage.js'
9
+ import { createExport, importExport } from './lib/transfer.js'
9
10
  import { getPassword, listPasswordAliases, removePassword, setPassword } from './lib/vault.js'
10
11
 
11
12
  const STATE_ROUTE = '/dsh-ssh-manage/state'
@@ -173,6 +174,15 @@ export async function runAction(paths, action, params = {}) {
173
174
  if (!password) throw new Error('No password is stored for this host')
174
175
  return { password }
175
176
  }
177
+ case 'data/export': {
178
+ if (typeof params.passphrase !== 'string' || params.passphrase.length === 0) throw new Error('Passphrase is required')
179
+ return await createExport(paths, params.passphrase)
180
+ }
181
+ case 'data/import': {
182
+ if (typeof params.passphrase !== 'string' || params.passphrase.length === 0) throw new Error('Passphrase is required')
183
+ const result = await importExport(paths, params.export, params.passphrase)
184
+ return { ...result, state: await state(paths) }
185
+ }
176
186
  case 'host/metadata': {
177
187
  const alias = requireString(params, 'host')
178
188
  await findHost(paths, alias)
@@ -0,0 +1,202 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto'
2
+ import { listSshHosts, saveSshHost } from './ssh-config.js'
3
+ import { loadMetadata, updateMetadata } from './metadata.js'
4
+ import { loadVault, saveVault } from './vault.js'
5
+
6
+ const FORMAT = 'dsh-ssh-manage-export'
7
+ const VERSION = 1
8
+ const ALGORITHM = 'aes-256-gcm'
9
+ const KDF = 'scrypt'
10
+ const KDF_N = 16384
11
+ const KDF_R = 8
12
+ const KDF_P = 1
13
+ const KEY_BYTES = 32
14
+ const SALT_BYTES = 16
15
+ const IV_BYTES = 12
16
+ const MAX_HOSTS = 500
17
+ const MAX_METADATA = 1000
18
+ const MAX_PASSWORDS = 1000
19
+ const ALIAS_RE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,63}$/
20
+
21
+ function assertPassphrase(passphrase) {
22
+ if (typeof passphrase !== 'string' || passphrase.length < 8) {
23
+ throw new Error('Passphrase must be at least 8 characters')
24
+ }
25
+ }
26
+
27
+ function deriveKey(passphrase, salt) {
28
+ return scryptSync(Buffer.from(passphrase, 'utf8'), salt, KEY_BYTES, {
29
+ N: KDF_N,
30
+ r: KDF_R,
31
+ p: KDF_P,
32
+ maxmem: 64 * 1024 * 1024,
33
+ })
34
+ }
35
+
36
+ function encryptSecrets(passwords, passphrase) {
37
+ const salt = randomBytes(SALT_BYTES)
38
+ const iv = randomBytes(IV_BYTES)
39
+ const key = deriveKey(passphrase, salt)
40
+ const cipher = createCipheriv(ALGORITHM, key, iv)
41
+ const ciphertext = Buffer.concat([
42
+ cipher.update(JSON.stringify({ passwords }), 'utf8'),
43
+ cipher.final(),
44
+ ])
45
+ return {
46
+ version: VERSION,
47
+ algorithm: ALGORITHM,
48
+ kdf: KDF,
49
+ kdfN: KDF_N,
50
+ kdfR: KDF_R,
51
+ kdfP: KDF_P,
52
+ salt: salt.toString('base64'),
53
+ iv: iv.toString('base64'),
54
+ tag: cipher.getAuthTag().toString('base64'),
55
+ ciphertext: ciphertext.toString('base64'),
56
+ }
57
+ }
58
+
59
+ function decryptSecrets(payload, passphrase) {
60
+ if (!payload || typeof payload !== 'object' || payload.algorithm !== ALGORITHM || payload.kdf !== KDF) {
61
+ throw new Error('Unsupported export secrets format')
62
+ }
63
+ const n = Number(payload.kdfN)
64
+ const r = Number(payload.kdfR)
65
+ const p = Number(payload.kdfP)
66
+ if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p) || n < 1024 || n > 65536 || r < 1 || r > 16 || p < 1 || p > 8) {
67
+ throw new Error('Unsupported export KDF parameters')
68
+ }
69
+ try {
70
+ const salt = Buffer.from(String(payload.salt ?? ''), 'base64')
71
+ const iv = Buffer.from(String(payload.iv ?? ''), 'base64')
72
+ const tag = Buffer.from(String(payload.tag ?? ''), 'base64')
73
+ if (salt.length !== SALT_BYTES || iv.length !== IV_BYTES || tag.length === 0) throw new Error('bad envelope')
74
+ const key = scryptSync(Buffer.from(passphrase, 'utf8'), salt, KEY_BYTES, {
75
+ N: n,
76
+ r,
77
+ p,
78
+ maxmem: 64 * 1024 * 1024,
79
+ })
80
+ const decipher = createDecipheriv(ALGORITHM, key, iv)
81
+ decipher.setAuthTag(tag)
82
+ const plaintext = Buffer.concat([
83
+ decipher.update(Buffer.from(String(payload.ciphertext ?? ''), 'base64')),
84
+ decipher.final(),
85
+ ]).toString('utf8')
86
+ const parsed = JSON.parse(plaintext)
87
+ if (!parsed || typeof parsed !== 'object' || !parsed.passwords || typeof parsed.passwords !== 'object' || Array.isArray(parsed.passwords)) {
88
+ throw new Error('bad payload')
89
+ }
90
+ return parsed.passwords
91
+ } catch {
92
+ throw new Error('Cannot decrypt export: wrong passphrase or corrupted file')
93
+ }
94
+ }
95
+
96
+ function hostRecord(host) {
97
+ return {
98
+ host: String(host?.host ?? ''),
99
+ aliases: Array.isArray(host?.aliases) ? host.aliases.map(String) : [],
100
+ hostname: String(host?.hostname ?? ''),
101
+ user: String(host?.user ?? ''),
102
+ port: String(host?.port ?? ''),
103
+ identityFile: String(host?.identityFile ?? ''),
104
+ proxyJump: String(host?.proxyJump ?? ''),
105
+ forwardAgent: host?.forwardAgent === true,
106
+ }
107
+ }
108
+
109
+ export async function createExport(paths, passphrase) {
110
+ assertPassphrase(passphrase)
111
+ const { hosts } = await listSshHosts(paths)
112
+ const metadata = await loadMetadata(paths)
113
+ const vault = await loadVault(paths)
114
+ const passwords = {}
115
+ for (const [alias, password] of Object.entries(vault.records)) {
116
+ if (ALIAS_RE.test(alias) && typeof password === 'string' && password.length > 0) passwords[alias] = password
117
+ }
118
+ return {
119
+ format: FORMAT,
120
+ version: VERSION,
121
+ exportedAt: new Date().toISOString(),
122
+ hosts: hosts.map(hostRecord),
123
+ metadata: metadata.entries,
124
+ secrets: encryptSecrets(passwords, passphrase),
125
+ }
126
+ }
127
+
128
+ function validateEnvelope(exported) {
129
+ if (!exported || typeof exported !== 'object' || Array.isArray(exported)) throw new Error('Invalid export file')
130
+ if (exported.format !== FORMAT) throw new Error('Not a dsh-ssh-manage export file')
131
+ if (exported.version !== VERSION) throw new Error(`Unsupported export version: ${String(exported.version)}`)
132
+ if (!Array.isArray(exported.hosts) || exported.hosts.length > MAX_HOSTS) throw new Error('Invalid or too many hosts in export')
133
+ if (!exported.metadata || typeof exported.metadata !== 'object' || Array.isArray(exported.metadata) || Object.keys(exported.metadata).length > MAX_METADATA) {
134
+ throw new Error('Invalid metadata in export')
135
+ }
136
+ return {
137
+ hosts: exported.hosts,
138
+ metadata: exported.metadata,
139
+ secrets: exported.secrets,
140
+ }
141
+ }
142
+
143
+ function readableError(error) {
144
+ return error instanceof Error ? error.message : String(error)
145
+ }
146
+
147
+ /**
148
+ * Merge an export into the local config/metadata/vault. Existing hosts with the
149
+ * same alias are updated in place; nothing is deleted. Per-host failures are
150
+ * returned so a single malformed entry cannot abort the whole restore.
151
+ */
152
+ export async function importExport(paths, exported, passphrase) {
153
+ assertPassphrase(passphrase)
154
+ const envelope = validateEnvelope(exported)
155
+ if (!envelope.secrets) throw new Error('This export does not contain encrypted passwords')
156
+ const passwords = decryptSecrets(envelope.secrets, passphrase)
157
+ if (Object.keys(passwords).length > MAX_PASSWORDS) throw new Error('Too many passwords in export')
158
+
159
+ const before = await listSshHosts(paths)
160
+ const existing = new Set(before.hosts.map((host) => host.host))
161
+ let created = 0
162
+ let updated = 0
163
+ const errors = []
164
+
165
+ for (const raw of envelope.hosts) {
166
+ try {
167
+ const host = hostRecord(raw)
168
+ if (!ALIAS_RE.test(host.host)) throw new Error('Invalid SSH host alias')
169
+ const exists = existing.has(host.host)
170
+ await saveSshHost({ paths, host, originalHost: exists ? host.host : null })
171
+ if (exists) updated += 1
172
+ else created += 1
173
+ } catch (error) {
174
+ errors.push(`${String(raw?.host ?? '?')}: ${readableError(error)}`)
175
+ }
176
+ }
177
+
178
+ let metadataCount = 0
179
+ for (const [alias, data] of Object.entries(envelope.metadata)) {
180
+ if (!ALIAS_RE.test(alias)) continue
181
+ try {
182
+ await updateMetadata(paths, alias, data)
183
+ metadataCount += 1
184
+ } catch (error) {
185
+ errors.push(`metadata ${alias}: ${readableError(error)}`)
186
+ }
187
+ }
188
+
189
+ let passwordCount = 0
190
+ const currentVault = await loadVault(paths)
191
+ const merged = { ...currentVault.records }
192
+ for (const [alias, password] of Object.entries(passwords)) {
193
+ if (!ALIAS_RE.test(alias) || typeof password !== 'string' || password.length === 0) continue
194
+ merged[alias] = password
195
+ passwordCount += 1
196
+ }
197
+ if (passwordCount > 0) await saveVault(paths, merged)
198
+
199
+ return { created, updated, metadata: metadataCount, passwords: passwordCount, errors }
200
+ }
201
+
202
+ export const __test = { encryptSecrets, decryptSecrets, hostRecord }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ssh-manage",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
4
4
  "description": "A DSH sidebar plugin for managing SSH host profiles with a real interactive PTY terminal.",
5
5
  "license": "MIT",
6
6
  "private": false,