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 +6 -4
- package/README.md +5 -4
- package/client.js +318 -4
- package/index.js +10 -0
- package/lib/transfer.js +202 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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)
|
package/lib/transfer.js
ADDED
|
@@ -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 }
|