dsh-ssh-manage 0.2.1 → 0.2.2

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
@@ -96,16 +96,16 @@ Creating a host requires alias, hostname and password. The password is not writt
96
96
 
97
97
  ### From npm (recommended)
98
98
 
99
- npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.2.1`).
99
+ npm package: [`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage) (current version `0.2.2`).
100
100
 
101
101
  ```text
102
- plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.1
102
+ plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.2
103
103
  ```
104
104
 
105
105
  Or with the DSH CLI:
106
106
 
107
107
  ```bash
108
- dsh plugin add dsh-ssh-manage@0.2.1
108
+ dsh plugin add dsh-ssh-manage@0.2.2
109
109
  ```
110
110
 
111
111
  After installation, refresh the Web page; the sidebar shows an "SSH Hosts" entry with id `dsh-ssh-manage`.
@@ -137,7 +137,7 @@ and make sure the package is resolvable by the profile (for example `link:` to t
137
137
 
138
138
  | | Requirement |
139
139
  | --- | --- |
140
- | **DSH** | **`>=0.2.1-rc.3 <0.3.0`** (validated on DSH Desktop `0.2.1-rc.3`) |
140
+ | **DSH** | **`>=0.2.2-rc.3 <0.3.0`** (validated on DSH Desktop `0.2.2-rc.3`) |
141
141
  | **Node.js** | `>=20` (follows the DSH runtime) |
142
142
  | **Platform** | Validated on macOS; the test suite only uses Node built-ins and runs cross-platform |
143
143
  | **Network** | Reachable SSH port (22 by default, or the configured port) |
package/README.md CHANGED
@@ -98,16 +98,16 @@
98
98
 
99
99
  ### 从 npm 安装(推荐)
100
100
 
101
- npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.2.1`)。
101
+ npm 包:[`dsh-ssh-manage`](https://www.npmjs.com/package/dsh-ssh-manage)(当前版本 `0.2.2`)。
102
102
 
103
103
  ```text
104
- plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.1
104
+ plugin_manager action=install_bundle target=dsh-ssh-manage@0.2.2
105
105
  ```
106
106
 
107
107
  或使用 DSH CLI:
108
108
 
109
109
  ```bash
110
- dsh plugin add dsh-ssh-manage@0.2.1
110
+ dsh plugin add dsh-ssh-manage@0.2.2
111
111
  ```
112
112
 
113
113
  安装后刷新 Web 页面,左侧栏会出现「SSH 主机」入口,入口 id 为 `dsh-ssh-manage`。
@@ -139,7 +139,7 @@ plugin_manager action=install_bundle target=<仓库绝对路径>/dsh-ssh-manage
139
139
 
140
140
  | | 要求 |
141
141
  | --- | --- |
142
- | **DSH** | **`>=0.2.1-rc.3 <0.3.0`**(实机验证版本:DSH Desktop `0.2.1-rc.3`) |
142
+ | **DSH** | **`>=0.2.2-rc.3 <0.3.0`**(实机验证版本:DSH Desktop `0.2.2-rc.3`) |
143
143
  | **Node.js** | `>=20`(跟随 DSH 内置运行时) |
144
144
  | **平台** | 实机验证 macOS;测试套件只依赖 Node 内置模块,可跨平台运行 |
145
145
  | **网络** | 需要能访问目标 SSH 主机的 22 端口(或配置的端口) |
package/client.js CHANGED
@@ -16,6 +16,8 @@ window.__ModuleLoader__.load({
16
16
  panel: ['SSH 主机', 'SSH Hosts'],
17
17
  'tabs.hosts': ['主机', 'Hosts'],
18
18
  'tabs.sessions': ['会话', 'Sessions'],
19
+ 'session.restoring': ['正在恢复终端会话…', 'Restoring terminal sessions…'],
20
+ 'session.tooMany': ['最多同时打开 16 个终端会话,请先关闭一个。', 'At most 16 terminal sessions can be open. Close one first.'],
19
21
  'tabs.batch': ['批量命令', 'Batch'],
20
22
  'tabs.settings': ['设置', 'Settings'],
21
23
  'session.new': ['新建连接', 'New connection'],
@@ -141,6 +143,7 @@ window.__ModuleLoader__.load({
141
143
  'editor.required': ['请填写别名和服务器地址。', 'Alias and hostname are required.'],
142
144
  'editor.invalidAlias': ['别名只能包含字母、数字、点、下划线、@ 和短横线。', 'Alias may only contain letters, digits, dot, underscore, @ and dash.'],
143
145
  'editor.delete': ['删除这台主机', 'Delete this host'],
146
+ 'editor.discardChanges': ['有未保存的修改,确定放弃?', 'Discard unsaved changes?'],
144
147
  'drawer.files': ['文件', 'Files'],
145
148
  'drawer.details': ['详情', 'Details'],
146
149
  'terminal.loading': ['正在加载终端…', 'Loading terminal…'],
@@ -1180,6 +1183,8 @@ window.__ModuleLoader__.load({
1180
1183
  const [clearSignals, setClearSignals] = React.useState({})
1181
1184
  const toastTimer = React.useRef(null)
1182
1185
  const floatingRef = React.useRef(null)
1186
+ const editorCloseGuard = React.useRef(null)
1187
+ const groupsCloseGuard = React.useRef(null)
1183
1188
 
1184
1189
  const notify = React.useCallback((message, tone = 'info') => {
1185
1190
  if (toastTimer.current) clearTimeout(toastTimer.current)
@@ -1375,6 +1380,10 @@ window.__ModuleLoader__.load({
1375
1380
  if (existing) {
1376
1381
  setActiveSessionId(existing.id)
1377
1382
  } else {
1383
+ if (sessions.length >= MAX_TERMINAL_SESSIONS) {
1384
+ notify(t('session.tooMany'), 'error')
1385
+ return
1386
+ }
1378
1387
  const id = newPtySessionId()
1379
1388
  ptyActiveIds.add(id)
1380
1389
  setSessions((prev) => [...prev, { id, hostAlias: host.host }])
@@ -1402,6 +1411,7 @@ window.__ModuleLoader__.load({
1402
1411
  }
1403
1412
 
1404
1413
  function clearSession(id) {
1414
+ clearPtySession(id)
1405
1415
  setClearSignals((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 }))
1406
1416
  }
1407
1417
 
@@ -1584,11 +1594,15 @@ window.__ModuleLoader__.load({
1584
1594
  return
1585
1595
  }
1586
1596
  if (editor) {
1587
- setEditor(null)
1597
+ const guard = editorCloseGuard.current
1598
+ if (typeof guard === 'function') guard()
1599
+ else setEditor(null)
1588
1600
  return
1589
1601
  }
1590
1602
  if (groupsOpen) {
1591
- setGroupsOpen(false)
1603
+ const guard = groupsCloseGuard.current
1604
+ if (typeof guard === 'function') guard()
1605
+ else setGroupsOpen(false)
1592
1606
  return
1593
1607
  }
1594
1608
  if (drawer) setDrawer(null)
@@ -1680,6 +1694,8 @@ window.__ModuleLoader__.load({
1680
1694
  setFloating,
1681
1695
  setEditor,
1682
1696
  setDrawer,
1697
+ editorCloseGuard,
1698
+ groupsCloseGuard,
1683
1699
  treeWidth,
1684
1700
  setTreeWidth,
1685
1701
  sessions,
@@ -1934,7 +1950,7 @@ window.__ModuleLoader__.load({
1934
1950
  'div',
1935
1951
  { className: 'ssh-loadingPage' },
1936
1952
  h(Spinner, { size: 22 }),
1937
- h('span', null, t('state.loading')),
1953
+ h('span', null, sessions.length > 0 ? t('session.restoring') : t('state.loading')),
1938
1954
  )
1939
1955
  : !snapshot && error
1940
1956
  ? h(
@@ -1951,7 +1967,7 @@ window.__ModuleLoader__.load({
1951
1967
  : effectiveTab === 'batch'
1952
1968
  ? h(BatchTab, { model })
1953
1969
  : h(SettingsTab, { model }),
1954
- sessions.length > 0
1970
+ snapshot && sessions.length > 0
1955
1971
  ? h(
1956
1972
  'div',
1957
1973
  { className: 'ssh-sessionLayer', style: { display: effectiveTab === 'sessions' ? 'flex' : 'none' } },
@@ -3039,11 +3055,73 @@ window.__ModuleLoader__.load({
3039
3055
  }
3040
3056
  }
3041
3057
 
3058
+ const terminalThemeListeners = new Set()
3059
+ let terminalThemeObserver = null
3060
+ let terminalThemeMedia = null
3061
+ let terminalThemeScheduled = false
3062
+
3063
+ function emitTerminalTheme() {
3064
+ for (const listener of [...terminalThemeListeners]) {
3065
+ try { listener() } catch {}
3066
+ }
3067
+ }
3068
+
3069
+ function scheduleTerminalTheme() {
3070
+ if (terminalThemeScheduled) return
3071
+ terminalThemeScheduled = true
3072
+ const run = () => {
3073
+ terminalThemeScheduled = false
3074
+ emitTerminalTheme()
3075
+ }
3076
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run)
3077
+ else setTimeout(run, 16)
3078
+ }
3079
+
3080
+ function ensureTerminalThemeWatcher() {
3081
+ if (typeof MutationObserver !== 'undefined' && !terminalThemeObserver) {
3082
+ terminalThemeObserver = new MutationObserver(scheduleTerminalTheme)
3083
+ const options = {
3084
+ attributes: true,
3085
+ attributeFilter: ['class', 'style', 'data-theme', 'data-color-mode', 'data-ds-dark-theme', 'data-ds-theme-source'],
3086
+ }
3087
+ terminalThemeObserver.observe(document.documentElement, options)
3088
+ if (document.body) terminalThemeObserver.observe(document.body, options)
3089
+ }
3090
+ if (typeof window !== 'undefined' && typeof window.matchMedia === 'function' && !terminalThemeMedia) {
3091
+ terminalThemeMedia = window.matchMedia('(prefers-color-scheme: dark)')
3092
+ const onChange = () => scheduleTerminalTheme()
3093
+ if (typeof terminalThemeMedia.addEventListener === 'function') terminalThemeMedia.addEventListener('change', onChange)
3094
+ else if (typeof terminalThemeMedia.addListener === 'function') terminalThemeMedia.addListener(onChange)
3095
+ terminalThemeMedia.__sshThemeChange = onChange
3096
+ }
3097
+ }
3098
+
3099
+ function subscribeTerminalTheme(listener) {
3100
+ terminalThemeListeners.add(listener)
3101
+ ensureTerminalThemeWatcher()
3102
+ return () => {
3103
+ terminalThemeListeners.delete(listener)
3104
+ if (terminalThemeListeners.size > 0) return
3105
+ if (terminalThemeObserver) {
3106
+ terminalThemeObserver.disconnect()
3107
+ terminalThemeObserver = null
3108
+ }
3109
+ if (terminalThemeMedia) {
3110
+ const onChange = terminalThemeMedia.__sshThemeChange
3111
+ if (typeof terminalThemeMedia.removeEventListener === 'function') terminalThemeMedia.removeEventListener('change', onChange)
3112
+ else if (typeof terminalThemeMedia.removeListener === 'function') terminalThemeMedia.removeListener(onChange)
3113
+ terminalThemeMedia = null
3114
+ }
3115
+ }
3116
+ }
3117
+
3042
3118
  const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
3043
3119
  const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
3120
+ const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
3044
3121
  const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
3045
3122
  const ptyActiveIds = new Set()
3046
3123
  const ptyClosedIds = new Set()
3124
+ const MAX_TERMINAL_SESSIONS = 16
3047
3125
  const ptyStreamLostHandlers = new Set()
3048
3126
 
3049
3127
  function closePtySession(id) {
@@ -3065,13 +3143,23 @@ window.__ModuleLoader__.load({
3065
3143
  return () => ptyStreamLostHandlers.delete(handler)
3066
3144
  }
3067
3145
 
3068
- function resetPtyStream() {
3146
+ function resetPtyStream(reason = new Error('Terminal event stream closed')) {
3147
+ const wasReady = ptyStream.ready === true
3148
+ const rejectPending = ptyStream.reject
3149
+ ptyStream.reject = null
3069
3150
  if (ptyStream.source) {
3070
3151
  try { ptyStream.source.close() } catch {}
3071
3152
  }
3072
3153
  ptyStream.source = null
3073
3154
  ptyStream.ready = false
3074
3155
  ptyStream.promise = null
3156
+ if (rejectPending) {
3157
+ try { rejectPending(reason) } catch {}
3158
+ }
3159
+ // A stream that never finished connecting is not a lost terminal yet;
3160
+ // its waiting panes get the rejected ensure promise and keep their
3161
+ // normal connecting/error overlay.
3162
+ if (!wasReady) return
3075
3163
  for (const handler of [...ptyStreamLostHandlers]) {
3076
3164
  try { handler() } catch {}
3077
3165
  }
@@ -3085,8 +3173,18 @@ window.__ModuleLoader__.load({
3085
3173
  }
3086
3174
 
3087
3175
  function ensurePtyStream() {
3088
- if (ptyStream.ready && ptyStream.source && ptyStream.source.readyState === 1) return Promise.resolve()
3089
- if (ptyStream.source && ptyStream.source.readyState !== 1) resetPtyStream()
3176
+ if (ptyStream.source) {
3177
+ const state = ptyStream.source.readyState
3178
+ if (state === 1 && ptyStream.ready) return Promise.resolve()
3179
+ // CONNECTING: all callers must share the one in-flight promise; never
3180
+ // tear it down. This is the multi-tab restore / rapid-second-connect
3181
+ // race that used to leave N-1 panes in a permanent error overlay.
3182
+ if (state === 0) {
3183
+ if (ptyStream.promise) return ptyStream.promise
3184
+ try { ptyStream.source.close() } catch {}
3185
+ ptyStream.source = null
3186
+ } else if (state === 2) resetPtyStream()
3187
+ }
3090
3188
  if (ptyStream.promise) return ptyStream.promise
3091
3189
  ptyStream.promise = new Promise((resolve, reject) => {
3092
3190
  let settled = false
@@ -3100,14 +3198,17 @@ window.__ModuleLoader__.load({
3100
3198
  ptyStream.source = null
3101
3199
  ptyStream.ready = false
3102
3200
  ptyStream.promise = null
3201
+ ptyStream.reject = null
3103
3202
  reject(error)
3104
3203
  }
3105
3204
  ptyStream.source = source
3205
+ ptyStream.reject = reject
3106
3206
  timer = setTimeout(() => fail(new Error('Terminal event stream timed out')), 10_000)
3107
3207
  source.addEventListener('open', () => {
3108
3208
  if (settled) return
3109
3209
  settled = true
3110
3210
  clearTimeout(timer)
3211
+ ptyStream.reject = null
3111
3212
  ptyStream.ready = true
3112
3213
  resolve()
3113
3214
  })
@@ -3161,6 +3262,11 @@ window.__ModuleLoader__.load({
3161
3262
  }
3162
3263
  }
3163
3264
 
3265
+ function clearPtySession(id) {
3266
+ if (!id) return
3267
+ void ptyPost(PTY_CLEAR_ROUTE, { id }, 15_000).catch(() => {})
3268
+ }
3269
+
3164
3270
  function decodeBase64(text) {
3165
3271
  const binary = atob(String(text ?? ''))
3166
3272
  const bytes = new Uint8Array(binary.length)
@@ -3179,6 +3285,8 @@ window.__ModuleLoader__.load({
3179
3285
  const [status, setStatus] = React.useState('loading')
3180
3286
  const [message, setMessage] = React.useState('')
3181
3287
  const [retryToken, setRetryToken] = React.useState(0)
3288
+ const clearRequested = React.useRef(false)
3289
+ const syncedRef = React.useRef(false)
3182
3290
 
3183
3291
  React.useEffect(() => {
3184
3292
  activeRef.current = active === true
@@ -3194,7 +3302,12 @@ window.__ModuleLoader__.load({
3194
3302
  }, [active])
3195
3303
 
3196
3304
  React.useEffect(() => {
3197
- if (!clearSignal || !termRef.current) return
3305
+ if (!clearSignal) return
3306
+ // Only a clear issued before attach needs to survive into the attach
3307
+ // replay. A clear on a live, synced terminal is handled by the server
3308
+ // route directly and must not truncate the next attach replay.
3309
+ clearRequested.current = syncedRef.current !== true
3310
+ if (!termRef.current) return
3198
3311
  try {
3199
3312
  termRef.current.clear()
3200
3313
  termRef.current.write('\x1b[H\x1b[2J')
@@ -3217,6 +3330,8 @@ window.__ModuleLoader__.load({
3217
3330
  let opened = false
3218
3331
  let inputChain = Promise.resolve()
3219
3332
  let streamLost = null
3333
+ let themeUnsub = null
3334
+ syncedRef.current = false
3220
3335
  setStatus('loading')
3221
3336
  setMessage('')
3222
3337
 
@@ -3278,6 +3393,10 @@ window.__ModuleLoader__.load({
3278
3393
  termRef.current = term
3279
3394
  fitRef.current = fit
3280
3395
  try { fit.fit() } catch {}
3396
+ themeUnsub = subscribeTerminalTheme(() => {
3397
+ if (removed || !term) return
3398
+ try { term.options.theme = terminalThemeFor(container) } catch {}
3399
+ })
3281
3400
  term.onData((data) => {
3282
3401
  const id = sessionKey
3283
3402
  if (!id || !data || !opened) return
@@ -3327,6 +3446,14 @@ window.__ModuleLoader__.load({
3327
3446
  let lastSeq = 0
3328
3447
  const pendingEvents = []
3329
3448
 
3449
+ const clearTerminal = () => {
3450
+ if (!term) return
3451
+ try {
3452
+ term.clear()
3453
+ term.write('\x1b[H\x1b[2J')
3454
+ } catch {}
3455
+ }
3456
+
3330
3457
  const applyOutput = (event) => {
3331
3458
  const seq = Number(event.seq)
3332
3459
  if (Number.isFinite(seq)) {
@@ -3340,6 +3467,9 @@ window.__ModuleLoader__.load({
3340
3467
  if (event.kind === 'output') {
3341
3468
  if (!synced) pendingEvents.push(event)
3342
3469
  else applyOutput(event)
3470
+ } else if (event.kind === 'clear') {
3471
+ if (!synced) pendingEvents.push(event)
3472
+ else clearTerminal()
3343
3473
  } else if (event.kind === 'exit') {
3344
3474
  if (!synced) {
3345
3475
  pendingEvents.push(event)
@@ -3386,18 +3516,23 @@ window.__ModuleLoader__.load({
3386
3516
  closePtySession(id)
3387
3517
  return
3388
3518
  }
3389
- if (removed) return
3390
- if (value && value.output) {
3519
+ if (removed || stopped || finished) return
3520
+ if (clearRequested.current) {
3521
+ clearRequested.current = false
3522
+ clearTerminal()
3523
+ clearPtySession(id)
3524
+ } else if (value && value.output) {
3391
3525
  try { term.write(decodeBase64(value.output)) } catch {}
3392
3526
  }
3393
3527
  const seq = Number(value?.seq)
3394
3528
  if (Number.isFinite(seq)) lastSeq = seq
3395
3529
  synced = true
3396
3530
  for (const event of pendingEvents.splice(0)) {
3397
- if (finished) break
3531
+ if (finished || stopped) break
3398
3532
  handleEvent(event)
3399
3533
  }
3400
- if (finished) return
3534
+ if (finished || stopped || removed) return
3535
+ syncedRef.current = true
3401
3536
  opened = true
3402
3537
  setStatus('ready')
3403
3538
  setMessage('')
@@ -3422,6 +3557,7 @@ window.__ModuleLoader__.load({
3422
3557
 
3423
3558
  return () => {
3424
3559
  removed = true
3560
+ if (themeUnsub) themeUnsub()
3425
3561
  if (streamLost) streamLost()
3426
3562
  if (resizeObserver) resizeObserver.disconnect()
3427
3563
  teardown()
@@ -3809,6 +3945,28 @@ window.__ModuleLoader__.load({
3809
3945
  setDraft((prev) => ({ ...prev, [field]: value }))
3810
3946
  }
3811
3947
 
3948
+ const dirty = password !== '' || JSON.stringify(draft) !== JSON.stringify(state.draft)
3949
+ const requestClose = () => {
3950
+ if (busy) return
3951
+ if (!dirty) {
3952
+ model.setEditor(null)
3953
+ return
3954
+ }
3955
+ let discard = true
3956
+ try {
3957
+ discard = typeof window.confirm !== 'function' || window.confirm(t('editor.discardChanges'))
3958
+ } catch {}
3959
+ if (discard) model.setEditor(null)
3960
+ }
3961
+
3962
+ React.useEffect(() => {
3963
+ const guard = () => requestClose()
3964
+ model.editorCloseGuard.current = guard
3965
+ return () => {
3966
+ if (model.editorCloseGuard.current === guard) model.editorCloseGuard.current = null
3967
+ }
3968
+ })
3969
+
3812
3970
  async function submit() {
3813
3971
  const alias = draft.host.trim()
3814
3972
  const hostname = draft.hostname.trim()
@@ -3864,7 +4022,7 @@ window.__ModuleLoader__.load({
3864
4022
  t('editor.delete'),
3865
4023
  )
3866
4024
  : h('span', { key: 'spacer', className: 'ssh-toolSpacer' }),
3867
- h(Button, { key: 'cancel', onClick: () => model.setEditor(null), disabled: busy }, t('common.cancel')),
4025
+ h(Button, { key: 'cancel', onClick: requestClose, disabled: busy }, t('common.cancel')),
3868
4026
  h(
3869
4027
  Button,
3870
4028
  { key: 'save', variant: 'primary', onClick: () => void submit(), disabled: busy },
@@ -3881,7 +4039,7 @@ window.__ModuleLoader__.load({
3881
4039
  width: 700,
3882
4040
  icon: h(IconTerminal, { size: 16 }),
3883
4041
  closeLabel: t('common.close'),
3884
- onClose: () => model.setEditor(null),
4042
+ onClose: requestClose,
3885
4043
  footer,
3886
4044
  },
3887
4045
  h(
@@ -4004,6 +4162,17 @@ window.__ModuleLoader__.load({
4004
4162
  const groups = buildGroups(hosts, t).filter((group) => group.key !== '')
4005
4163
  const [drafts, setDrafts] = React.useState(() => Object.fromEntries(groups.map((group) => [group.key, group.key])))
4006
4164
  const busy = isPending('groups')
4165
+ const requestClose = () => {
4166
+ if (!busy) model.setGroupsOpen(false)
4167
+ }
4168
+
4169
+ React.useEffect(() => {
4170
+ const guard = () => requestClose()
4171
+ model.groupsCloseGuard.current = guard
4172
+ return () => {
4173
+ if (model.groupsCloseGuard.current === guard) model.groupsCloseGuard.current = null
4174
+ }
4175
+ })
4007
4176
 
4008
4177
  async function commit(name) {
4009
4178
  const next = String(drafts[name] ?? '').trim()
@@ -4020,8 +4189,8 @@ window.__ModuleLoader__.load({
4020
4189
  width: 560,
4021
4190
  icon: h(IconFolder, { size: 16 }),
4022
4191
  closeLabel: t('common.close'),
4023
- onClose: () => model.setGroupsOpen(false),
4024
- footer: [h(Button, { key: 'close', onClick: () => model.setGroupsOpen(false) }, t('common.close'))],
4192
+ onClose: requestClose,
4193
+ footer: [h(Button, { key: 'close', onClick: requestClose }, t('common.close'))],
4025
4194
  },
4026
4195
  groups.length === 0
4027
4196
  ? h('div', { className: 'ssh-sectionHint' }, t('groups.empty'))
package/index.js CHANGED
@@ -17,6 +17,7 @@ const PTY_INPUT_ROUTE = '/dsh-ssh-manage/pty/input'
17
17
  const PTY_RESIZE_ROUTE = '/dsh-ssh-manage/pty/resize'
18
18
  const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
19
19
  const PTY_CLOSE_ROUTE = '/dsh-ssh-manage/pty/close'
20
+ const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
20
21
  const MAX_PTY_SESSIONS = 16
21
22
  const PTY_BUFFER_BYTES = 256 * 1024
22
23
  const PTY_IDLE_MS = 6 * 60 * 60 * 1000
@@ -75,14 +76,26 @@ function errorMessage(error) {
75
76
  }
76
77
 
77
78
  async function state(paths) {
78
- const [current, metadata, passwords] = await Promise.all([listSshHosts(paths), loadMetadata(paths), listPasswordAliases(paths)])
79
+ const [current, metadataResult, passwordsResult] = await Promise.all([
80
+ listSshHosts(paths),
81
+ loadMetadata(paths).then((value) => ({ value }), (error) => ({ error })),
82
+ listPasswordAliases(paths).then((value) => ({ value }), (error) => ({ error })),
83
+ ])
84
+ const warnings = []
85
+ let entries = {}
86
+ if ('error' in metadataResult) warnings.push({ store: 'metadata', message: errorMessage(metadataResult.error) })
87
+ else entries = metadataResult.value.entries
88
+ let passwords = []
89
+ if ('error' in passwordsResult) warnings.push({ store: 'vault', message: errorMessage(passwordsResult.error) })
90
+ else passwords = passwordsResult.value
79
91
  const passwordSet = new Set(passwords)
80
92
  const stateDir = storagePaths(paths).stateDir
81
93
  return {
82
94
  ...current,
83
95
  stateDir,
84
96
  backupDir: paths.backupDir || path.join(stateDir, 'backups'),
85
- hosts: current.hosts.map((host) => ({ ...host, ...(metadata.entries[host.host] || {}), passwordConfigured: passwordSet.has(host.host) })),
97
+ hosts: current.hosts.map((host) => ({ ...host, ...(entries[host.host] || {}), passwordConfigured: passwordSet.has(host.host) })),
98
+ warnings,
86
99
  }
87
100
  }
88
101
 
@@ -282,6 +295,16 @@ const ptyPendingIds = new Set()
282
295
  const ptyEventClients = new Set()
283
296
  let ptyOpening = 0
284
297
 
298
+ function createPtyRuntime() {
299
+ return { generation: 0, active: true, upgrades: new Set() }
300
+ }
301
+
302
+ const defaultPtyRuntime = createPtyRuntime()
303
+
304
+ function isPtyRuntimeActive(runtime, generation) {
305
+ return runtime.active && runtime.generation === generation
306
+ }
307
+
285
308
  function closeTextSession(id, notify = true) {
286
309
  const entry = ptyTextSessions.get(id)
287
310
  if (!entry) return
@@ -311,15 +334,17 @@ function broadcastPty(event) {
311
334
  for (const client of [...ptyEventClients]) writePtyEvent(client, event)
312
335
  }
313
336
 
314
- async function openTextSession(paths, payload) {
337
+ async function openTextSession(paths, payload, runtime = defaultPtyRuntime) {
338
+ if (!runtime.active) throw new Error('Terminal runtime is not active')
315
339
  const id = typeof payload?.id === 'string' ? payload.id.trim() : ''
316
340
  if (!/^[A-Za-z0-9_-]{8,80}$/.test(id)) throw new Error('A valid session id is required')
341
+ const cols = boundedInteger(payload?.cols, 20, 500, 120)
342
+ const rows = boundedInteger(payload?.rows, 5, 300, 32)
343
+ const alias = typeof payload?.host === 'string' ? payload.host.trim() : ''
344
+ if (alias === '') throw new Error('host is required')
317
345
  if (ptyTextSessions.has(id) || ptyPendingIds.has(id)) throw new Error('Terminal session already exists')
346
+ const generation = runtime.generation
318
347
  ptyPendingIds.add(id)
319
- const cols = boundedInteger(payload.cols, 20, 500, 120)
320
- const rows = boundedInteger(payload.rows, 5, 300, 32)
321
- const alias = typeof payload.host === 'string' ? payload.host.trim() : ''
322
- if (alias === '') throw new Error('host is required')
323
348
  ptyOpening += 1
324
349
  try {
325
350
  if (ptySessions.size + ptyTextSessions.size + ptyOpening > MAX_PTY_SESSIONS) {
@@ -327,7 +352,12 @@ async function openTextSession(paths, payload) {
327
352
  }
328
353
  const host = await findHost(paths, alias)
329
354
  const password = await requirePassword(paths, host)
355
+ if (!isPtyRuntimeActive(runtime, generation)) throw new Error('Terminal runtime is not active')
330
356
  const session = await openPty(host, password, { cols, rows })
357
+ if (!isPtyRuntimeActive(runtime, generation)) {
358
+ try { session.close() } catch {}
359
+ throw new Error('Terminal runtime is not active')
360
+ }
331
361
  if (ptyTextSessions.has(id)) {
332
362
  try { session.close() } catch {}
333
363
  throw new Error('Terminal session already exists')
@@ -363,7 +393,7 @@ async function openTextSession(paths, payload) {
363
393
  }
364
394
  }
365
395
 
366
- function registerPtyHttpRoutes(ctx, paths) {
396
+ function registerPtyHttpRoutes(ctx, paths, runtime) {
367
397
  const register = (route, label) => {
368
398
  ctx.effect(() => ctx.webServer.register(route), `dsh-ssh-manage: ${label}`)
369
399
  }
@@ -423,7 +453,7 @@ function registerPtyHttpRoutes(ctx, paths) {
423
453
  }
424
454
  const body = await readJsonBody(req)
425
455
  try {
426
- const value = await openTextSession(paths, body ?? {})
456
+ const value = await openTextSession(paths, body ?? {}, runtime)
427
457
  sendJson(res, 200, { ok: true, value })
428
458
  } catch (error) {
429
459
  sendJson(res, 200, { ok: false, error: { code: 'pty-open-failed', message: errorMessage(error) } })
@@ -473,6 +503,7 @@ function registerPtyHttpRoutes(ctx, paths) {
473
503
  [PTY_INPUT_ROUTE, 'input', 'pty input route'],
474
504
  [PTY_RESIZE_ROUTE, 'resize', 'pty resize route'],
475
505
  [PTY_CLOSE_ROUTE, 'close', 'pty close route'],
506
+ [PTY_CLEAR_ROUTE, 'clear', 'pty clear route'],
476
507
  ]) {
477
508
  register(
478
509
  {
@@ -504,6 +535,10 @@ function registerPtyHttpRoutes(ctx, paths) {
504
535
  0,
505
536
  0,
506
537
  )
538
+ } else if (action === 'clear') {
539
+ entry.buffer = []
540
+ entry.bufferBytes = 0
541
+ broadcastPty({ kind: 'clear', id })
507
542
  } else {
508
543
  closeTextSession(id)
509
544
  }
@@ -554,7 +589,11 @@ function upgradeRejection(ctx, req) {
554
589
  return rejection === undefined ? undefined : rejection
555
590
  }
556
591
 
557
- async function handlePtyUpgrade(ctx, paths, req, socket, head) {
592
+ async function handlePtyUpgrade(ctx, paths, req, socket, head, runtime = defaultPtyRuntime) {
593
+ if (!runtime.active) {
594
+ denyUpgrade(socket, 403)
595
+ return
596
+ }
558
597
  if (!isWebSocketUpgrade(req)) {
559
598
  try { socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') } catch {}
560
599
  socket.destroy()
@@ -567,22 +606,30 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
567
606
  }
568
607
  const ws = acceptWebSocket(req, socket, head)
569
608
  if (!ws) return
609
+ const generation = runtime.generation
570
610
  let session = null
571
611
  let opening = false
572
612
  let cleaned = false
573
613
  let paused = false
614
+ let shutdown = null
574
615
  const keepalive = setInterval(() => ws.ping(), 25_000)
575
616
  keepalive.unref?.()
576
617
  const cleanup = () => {
577
618
  if (cleaned) return
578
619
  cleaned = true
579
620
  clearInterval(keepalive)
621
+ if (shutdown) runtime.upgrades.delete(shutdown)
580
622
  if (session) {
581
623
  ptySessions.delete(session)
582
624
  try { session.close() } catch {}
583
625
  session = null
584
626
  }
585
627
  }
628
+ shutdown = () => {
629
+ cleanup()
630
+ try { ws.close(1001) } catch {}
631
+ }
632
+ runtime.upgrades.add(shutdown)
586
633
  ws.on('close', cleanup)
587
634
  socket.on('drain', () => {
588
635
  if (!paused || !session) return
@@ -599,7 +646,7 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
599
646
  }
600
647
  if (!message || typeof message.type !== 'string' || cleaned) return
601
648
  if (!session) {
602
- if (message.type !== 'open' || opening) return
649
+ if (message.type !== 'open' || opening || !runtime.active) return
603
650
  opening = true
604
651
  ptyOpening += 1
605
652
  try {
@@ -610,8 +657,9 @@ async function handlePtyUpgrade(ctx, paths, req, socket, head) {
610
657
  const rows = boundedInteger(message.rows, 5, 300, 32)
611
658
  const host = await findHost(paths, alias)
612
659
  const password = await requirePassword(paths, host)
660
+ if (!isPtyRuntimeActive(runtime, generation)) throw new Error('Terminal runtime is not active')
613
661
  session = await openPty(host, password, { cols, rows })
614
- if (cleaned) {
662
+ if (cleaned || !isPtyRuntimeActive(runtime, generation)) {
615
663
  try { session.close() } catch {}
616
664
  session = null
617
665
  return
@@ -714,7 +762,7 @@ function registerAssetRoutes(ctx) {
714
762
  }
715
763
  }
716
764
 
717
- export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_ATTACH_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
765
+ export const __test = { resolveSshPaths, listSshHosts, saveSshHost, deleteSshHost, runAction, managerTool, MAX_BODY_BYTES, PTY_ROUTE, PTY_EVENTS_ROUTE, PTY_OPEN_ROUTE, PTY_ATTACH_ROUTE, PTY_INPUT_ROUTE, PTY_RESIZE_ROUTE, PTY_CLOSE_ROUTE, PTY_CLEAR_ROUTE, MAX_PTY_SESSIONS, PTY_BUFFER_BYTES, ASSETS, handlePtyUpgrade, ptyTextSessions }
718
766
 
719
767
  export const inject = ['webServer']
720
768
 
@@ -723,6 +771,7 @@ export function apply(ctx) {
723
771
  throw new Error('dsh-ssh-manage requires DeepSeek Harness >=0.2.1-rc.3: ctx.webServer.register is unavailable.')
724
772
  }
725
773
  const paths = resolveSshPaths(process.env)
774
+ const runtime = createPtyRuntime()
726
775
  ctx.effect(
727
776
  () =>
728
777
  ctx.webServer.register({
@@ -772,7 +821,7 @@ export function apply(ctx) {
772
821
  }),
773
822
  'dsh-ssh-manage: action route',
774
823
  )
775
- registerPtyHttpRoutes(ctx, paths)
824
+ registerPtyHttpRoutes(ctx, paths, runtime)
776
825
  registerAssetRoutes(ctx)
777
826
  if (typeof ctx.webServer.tapIndex === 'function') {
778
827
  const tags = [
@@ -793,7 +842,7 @@ export function apply(ctx) {
793
842
  ctx.webServer.registerUpgrade({
794
843
  path: PTY_ROUTE,
795
844
  handler: (req, socket, head) => {
796
- void handlePtyUpgrade(ctx, paths, req, socket, head).catch(() => {
845
+ void handlePtyUpgrade(ctx, paths, req, socket, head, runtime).catch(() => {
797
846
  try { socket.destroy() } catch {}
798
847
  })
799
848
  },
@@ -802,6 +851,12 @@ export function apply(ctx) {
802
851
  )
803
852
  ctx.effect(
804
853
  () => () => {
854
+ runtime.active = false
855
+ runtime.generation += 1
856
+ for (const shutdown of [...runtime.upgrades]) {
857
+ try { shutdown() } catch {}
858
+ }
859
+ runtime.upgrades.clear()
805
860
  clearInterval(ptyIdleTimer)
806
861
  for (const session of ptySessions) {
807
862
  try { session.close() } catch {}
package/lib/metadata.js CHANGED
@@ -1,5 +1,4 @@
1
- import * as fs from 'node:fs/promises'
2
- import { atomicWriteFile, readJson, storagePaths } from './storage.js'
1
+ import { atomicWriteFile, readJson, storagePaths, withStoreLock } from './storage.js'
3
2
 
4
3
  function clean(value, max = 80) {
5
4
  return String(value ?? '').trim().slice(0, max)
@@ -27,7 +26,12 @@ function normalizeEntry(alias, input = {}) {
27
26
  }
28
27
  }
29
28
 
30
- export async function loadMetadata(paths) {
29
+ function metadataLockKey(paths) {
30
+ return `metadata:${storagePaths(paths).stateDir}`
31
+ }
32
+
33
+ /** Read the metadata store without taking the store lock; mutators hold it. */
34
+ async function loadMetadataUnlocked(paths) {
31
35
  const resolved = storagePaths(paths)
32
36
  const value = await readJson(resolved.metadataPath, {})
33
37
  if (!value || typeof value !== 'object' || Array.isArray(value)) return { paths: resolved, entries: {} }
@@ -35,24 +39,37 @@ export async function loadMetadata(paths) {
35
39
  return { paths: resolved, entries }
36
40
  }
37
41
 
38
- export async function saveMetadata(paths, entries) {
42
+ /** Write the metadata store without taking the store lock; mutators hold it. */
43
+ async function saveMetadataUnlocked(paths, entries) {
39
44
  const resolved = storagePaths(paths)
40
45
  const normalized = Object.fromEntries(Object.entries(entries || {}).map(([alias, data]) => [alias, normalizeEntry(alias, data)]))
41
46
  await atomicWriteFile(resolved.metadataPath, `${JSON.stringify(normalized, null, 2)}\n`, 0o600)
42
47
  return { saved: true }
43
48
  }
44
49
 
50
+ export async function loadMetadata(paths) {
51
+ return loadMetadataUnlocked(paths)
52
+ }
53
+
54
+ export async function saveMetadata(paths, entries) {
55
+ return withStoreLock(metadataLockKey(paths), () => saveMetadataUnlocked(paths, entries))
56
+ }
57
+
45
58
  export async function updateMetadata(paths, alias, data) {
46
- const current = await loadMetadata(paths)
47
- current.entries[alias] = normalizeEntry(alias, { ...current.entries[alias], ...data })
48
- await saveMetadata(current.paths, current.entries)
49
- return current.entries[alias]
59
+ return withStoreLock(metadataLockKey(paths), async () => {
60
+ const current = await loadMetadataUnlocked(paths)
61
+ current.entries[alias] = normalizeEntry(alias, { ...current.entries[alias], ...data })
62
+ await saveMetadataUnlocked(current.paths, current.entries)
63
+ return current.entries[alias]
64
+ })
50
65
  }
51
66
 
52
67
  export async function deleteMetadata(paths, alias) {
53
- const current = await loadMetadata(paths)
54
- delete current.entries[alias]
55
- await saveMetadata(current.paths, current.entries)
68
+ return withStoreLock(metadataLockKey(paths), async () => {
69
+ const current = await loadMetadataUnlocked(paths)
70
+ delete current.entries[alias]
71
+ await saveMetadataUnlocked(current.paths, current.entries)
72
+ })
56
73
  }
57
74
 
58
75
  export const __test = { normalizeEntry }
package/lib/ssh-config.js CHANGED
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto'
2
2
  import * as fs from 'node:fs/promises'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
+ import { withStoreLock } from './storage.js'
5
6
 
6
7
  const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.@-]{0,63}$/
7
8
  const OPTION_NAMES = new Map([
@@ -246,13 +247,15 @@ export async function listSshHosts(paths = resolveSshPaths()) {
246
247
  return { configPath: paths.configPath, hosts: parsed.hosts, exists: text.length > 0 }
247
248
  }
248
249
 
249
- export async function saveSshHost({ paths = resolveSshPaths(), host, originalHost } = {}) {
250
+ async function saveSshHostUnlocked({ paths, host, originalHost }) {
250
251
  const nextHost = validateHost(host)
251
252
  const aliasesProvided = Array.isArray(host?.aliases) && host.aliases.filter(Boolean).length > 0
252
253
  const previous = originalHost ? String(originalHost).trim() : null
254
+ if (previous !== null && (!HOST_RE.test(previous) || isWildPattern(previous))) throw new Error('Invalid SSH host alias')
253
255
  const text = await readConfig(paths)
254
256
  const parsed = parseSshConfig(text)
255
257
  const block = previous ? blockForHost(parsed, previous) : null
258
+ if (previous !== null && !block) throw new Error(`SSH host "${previous}" was not found`)
256
259
  const collision = blockForHost(parsed, nextHost.host)
257
260
  if (collision && (!block || collision.start !== block.start)) throw new Error(`SSH host "${nextHost.host}" already exists`)
258
261
  let lines = parsed.lines
@@ -274,7 +277,12 @@ export async function saveSshHost({ paths = resolveSshPaths(), host, originalHos
274
277
  return { saved: true, host: nextHost, backup }
275
278
  }
276
279
 
277
- export async function deleteSshHost({ paths = resolveSshPaths(), host } = {}) {
280
+ export async function saveSshHost(options = {}) {
281
+ const paths = options?.paths ?? resolveSshPaths()
282
+ return withStoreLock(`config:${paths.configPath}`, () => saveSshHostUnlocked({ ...options, paths }))
283
+ }
284
+
285
+ async function deleteSshHostUnlocked({ paths, host }) {
278
286
  const name = String(host ?? '').trim()
279
287
  if (!HOST_RE.test(name) || isWildPattern(name)) throw new Error('Invalid SSH host alias')
280
288
  const text = await readConfig(paths)
@@ -289,4 +297,9 @@ export async function deleteSshHost({ paths = resolveSshPaths(), host } = {}) {
289
297
  return { removed: true, backup }
290
298
  }
291
299
 
300
+ export async function deleteSshHost(options = {}) {
301
+ const paths = options?.paths ?? resolveSshPaths()
302
+ return withStoreLock(`config:${paths.configPath}`, () => deleteSshHostUnlocked({ ...options, paths }))
303
+ }
304
+
292
305
  export const __test = { patchHostBlock }
package/lib/storage.js CHANGED
@@ -3,6 +3,8 @@ import * as fs from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
 
5
5
  const KEY_BYTES = 32
6
+ const storeLocks = new Map()
7
+ const keyCreations = new Map()
6
8
 
7
9
  export function storagePaths(paths = {}) {
8
10
  const stateDir = paths.stateDir || path.join(paths.home || process.env.HOME || process.cwd(), '.dsh', '.ssh-manager')
@@ -15,6 +17,23 @@ export function storagePaths(paths = {}) {
15
17
  }
16
18
  }
17
19
 
20
+ /**
21
+ * Serialize one async read-modify-write sequence per store key. Callers must
22
+ * only take the lock around their own read/write pair; the unlocked helpers in
23
+ * the vault, metadata and SSH config modules exist so nested locks never wait
24
+ * on themselves.
25
+ */
26
+ export function withStoreLock(key, fn) {
27
+ const previous = storeLocks.get(key) ?? Promise.resolve()
28
+ const next = previous.then(() => fn())
29
+ const tail = next.then(() => undefined, () => undefined)
30
+ storeLocks.set(key, tail)
31
+ void tail.then(() => {
32
+ if (storeLocks.get(key) === tail) storeLocks.delete(key)
33
+ })
34
+ return next
35
+ }
36
+
18
37
  export async function atomicWriteFile(filePath, data, mode = 0o600) {
19
38
  await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 })
20
39
  const temporary = `${filePath}.tmp-${process.pid}-${randomBytes(6).toString('hex')}`
@@ -28,23 +47,51 @@ export async function atomicWriteFile(filePath, data, mode = 0o600) {
28
47
  }
29
48
  }
30
49
 
50
+ function decodeKey(raw) {
51
+ if (Buffer.isBuffer(raw) && raw.length === KEY_BYTES) return raw
52
+ const decoded = Buffer.from(String(raw).trim(), 'base64')
53
+ if (decoded.length !== KEY_BYTES) throw new Error('SSH manager key has an invalid length')
54
+ return decoded
55
+ }
56
+
57
+ async function readKey(keyPath) {
58
+ const raw = await fs.readFile(keyPath)
59
+ await fs.chmod(keyPath, 0o600).catch(() => {})
60
+ return decodeKey(raw)
61
+ }
62
+
63
+ /** Create the key exactly once per path in this process; adopt another writer's key on EEXIST. */
64
+ function createKeyOnce(keyPath) {
65
+ const pending = keyCreations.get(keyPath)
66
+ if (pending) return pending
67
+ const creation = (async () => {
68
+ const key = randomBytes(KEY_BYTES)
69
+ await fs.mkdir(path.dirname(keyPath), { recursive: true, mode: 0o700 })
70
+ try {
71
+ await fs.writeFile(keyPath, key.toString('base64'), { encoding: 'utf8', mode: 0o600, flag: 'wx' })
72
+ return key
73
+ } catch (error) {
74
+ if (error?.code !== 'EEXIST') throw error
75
+ // Another process created the key first: never overwrite an existing key.
76
+ return await readKey(keyPath)
77
+ }
78
+ })()
79
+ keyCreations.set(keyPath, creation)
80
+ const forget = () => {
81
+ if (keyCreations.get(keyPath) === creation) keyCreations.delete(keyPath)
82
+ }
83
+ void creation.then(forget, forget)
84
+ return creation
85
+ }
86
+
31
87
  export async function loadOrCreateKey(paths) {
32
88
  const resolved = storagePaths(paths)
33
89
  try {
34
- const raw = await fs.readFile(resolved.keyPath)
35
- await fs.chmod(resolved.keyPath, 0o600).catch(() => {})
36
- if (raw.length === KEY_BYTES) return raw
37
- const decoded = Buffer.from(raw.toString('utf8').trim(), 'base64')
38
- if (decoded.length !== KEY_BYTES) throw new Error('SSH manager key has an invalid length')
39
- return decoded
90
+ return await readKey(resolved.keyPath)
40
91
  } catch (error) {
41
92
  if (error?.code !== 'ENOENT') throw error
42
- const key = randomBytes(KEY_BYTES)
43
- await atomicWriteFile(resolved.keyPath, key.toString('base64'), 0o600)
44
- // The base64 representation is easier to inspect safely while retaining 256 bits.
45
- const encoded = await fs.readFile(resolved.keyPath, 'utf8')
46
- return Buffer.from(encoded.trim(), 'base64')
47
93
  }
94
+ return createKeyOnce(resolved.keyPath)
48
95
  }
49
96
 
50
97
  export async function readJson(filePath, fallback) {
package/lib/vault.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
2
- import { atomicWriteFile, loadOrCreateKey, readJson, storagePaths } from './storage.js'
2
+ import { atomicWriteFile, loadOrCreateKey, readJson, storagePaths, withStoreLock } from './storage.js'
3
3
 
4
4
  const ALGORITHM = 'aes-256-gcm'
5
5
  const IV_BYTES = 12
@@ -41,7 +41,8 @@ export function decryptRecords(payload, key) {
41
41
  }
42
42
  }
43
43
 
44
- export async function loadVault(paths) {
44
+ /** Read the vault without taking the store lock; callers that mutate must hold it. */
45
+ async function loadVaultUnlocked(paths) {
45
46
  const resolved = storagePaths(paths)
46
47
  const key = await loadOrCreateKey(resolved)
47
48
  const payload = await readJson(resolved.vaultPath, null)
@@ -49,7 +50,8 @@ export async function loadVault(paths) {
49
50
  return { paths: resolved, key, records }
50
51
  }
51
52
 
52
- export async function saveVault(paths, records) {
53
+ /** Write the vault without taking the store lock; callers that read-modify-write must hold it. */
54
+ async function saveVaultUnlocked(paths, records) {
53
55
  const resolved = storagePaths(paths)
54
56
  const key = await loadOrCreateKey(resolved)
55
57
  const payload = encryptRecords(normalizeRecords(records), key)
@@ -57,22 +59,38 @@ export async function saveVault(paths, records) {
57
59
  return { saved: true }
58
60
  }
59
61
 
62
+ function vaultLockKey(paths) {
63
+ return `vault:${storagePaths(paths).stateDir}`
64
+ }
65
+
66
+ export async function loadVault(paths) {
67
+ return loadVaultUnlocked(paths)
68
+ }
69
+
70
+ export async function saveVault(paths, records) {
71
+ return withStoreLock(vaultLockKey(paths), () => saveVaultUnlocked(paths, records))
72
+ }
73
+
60
74
  export async function setPassword(paths, alias, password) {
61
75
  const key = assertAlias(alias)
62
76
  if (typeof password !== 'string' || password.length === 0) throw new Error('Password is required')
63
- const vault = await loadVault(paths)
64
- vault.records[key] = password
65
- await saveVault(vault.paths, vault.records)
66
- return { saved: true, host: key }
77
+ return withStoreLock(vaultLockKey(paths), async () => {
78
+ const vault = await loadVaultUnlocked(paths)
79
+ vault.records[key] = password
80
+ await saveVaultUnlocked(vault.paths, vault.records)
81
+ return { saved: true, host: key }
82
+ })
67
83
  }
68
84
 
69
85
  export async function removePassword(paths, alias) {
70
86
  const key = assertAlias(alias)
71
- const vault = await loadVault(paths)
72
- const removed = Object.prototype.hasOwnProperty.call(vault.records, key)
73
- delete vault.records[key]
74
- if (removed) await saveVault(vault.paths, vault.records)
75
- return { removed }
87
+ return withStoreLock(vaultLockKey(paths), async () => {
88
+ const vault = await loadVaultUnlocked(paths)
89
+ const removed = Object.prototype.hasOwnProperty.call(vault.records, key)
90
+ delete vault.records[key]
91
+ if (removed) await saveVaultUnlocked(vault.paths, vault.records)
92
+ return { removed }
93
+ })
76
94
  }
77
95
 
78
96
  export async function hasPassword(paths, alias) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ssh-manage",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
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,