dsh-ssh-manage 0.2.0 → 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.0`).
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.0
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.0
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.0`)。
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.0
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.0
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'],
@@ -31,7 +33,7 @@ window.__ModuleLoader__.load({
31
33
  'toolbar.refresh': ['刷新', 'Refresh'],
32
34
  'toolbar.groups': ['分组', 'Groups'],
33
35
  'toolbar.new': ['新建主机', 'New host'],
34
- 'toolbar.newShort': ['+ SSH', '+ SSH'],
36
+ 'toolbar.newShort': ['SSH', 'SSH'],
35
37
  'toolbar.filter': ['只看收藏', 'Favorites only'],
36
38
  'toolbar.info': ['信息', 'Info'],
37
39
  'toolbar.settings': ['显示设置', 'View options'],
@@ -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…'],
@@ -280,6 +283,12 @@ window.__ModuleLoader__.load({
280
283
  return `${user}${target}${port}`
281
284
  }
282
285
 
286
+ // Table / session chrome shows only the hostname (the name column already
287
+ // carries the alias); the full user@host:port stays in titles and copy.
288
+ function hostTarget(host) {
289
+ return host.hostname || host.host
290
+ }
291
+
283
292
  function hostGroupKey(host) {
284
293
  return host.group || ''
285
294
  }
@@ -1048,7 +1057,7 @@ window.__ModuleLoader__.load({
1048
1057
  }
1049
1058
  let result = 0
1050
1059
  if (sort.key === 'system') result = String(a.system ?? '').localeCompare(String(b.system ?? ''))
1051
- else if (sort.key === 'address') result = hostAddress(a).localeCompare(hostAddress(b))
1060
+ else if (sort.key === 'address') result = hostTarget(a).localeCompare(hostTarget(b))
1052
1061
  else if (sort.key === 'notes') result = String(a.note ?? '').localeCompare(String(b.note ?? ''))
1053
1062
  else result = hostName(a).localeCompare(hostName(b), undefined, { numeric: true, sensitivity: 'base' })
1054
1063
  if (result === 0) result = a.host.localeCompare(b.host)
@@ -1068,10 +1077,10 @@ window.__ModuleLoader__.load({
1068
1077
  }
1069
1078
 
1070
1079
  function tableTracks(prefs) {
1071
- const tracks = ['32px', '112px', '92px', 'minmax(0,1.1fr)', 'minmax(0,1.2fr)']
1072
- if (prefs.info) tracks.push('minmax(250px,1.5fr)')
1073
- if (prefs.notes) tracks.push('minmax(0,0.9fr)')
1074
- tracks.push('176px', '32px')
1080
+ const tracks = ['32px', '96px', '80px', 'minmax(90px,1fr)', 'minmax(150px,1.25fr)']
1081
+ if (prefs.info) tracks.push('minmax(210px,1.4fr)')
1082
+ if (prefs.notes) tracks.push('minmax(120px,0.8fr)')
1083
+ tracks.push('150px', '28px')
1075
1084
  return tracks.join(' ')
1076
1085
  }
1077
1086
 
@@ -1174,6 +1183,8 @@ window.__ModuleLoader__.load({
1174
1183
  const [clearSignals, setClearSignals] = React.useState({})
1175
1184
  const toastTimer = React.useRef(null)
1176
1185
  const floatingRef = React.useRef(null)
1186
+ const editorCloseGuard = React.useRef(null)
1187
+ const groupsCloseGuard = React.useRef(null)
1177
1188
 
1178
1189
  const notify = React.useCallback((message, tone = 'info') => {
1179
1190
  if (toastTimer.current) clearTimeout(toastTimer.current)
@@ -1369,6 +1380,10 @@ window.__ModuleLoader__.load({
1369
1380
  if (existing) {
1370
1381
  setActiveSessionId(existing.id)
1371
1382
  } else {
1383
+ if (sessions.length >= MAX_TERMINAL_SESSIONS) {
1384
+ notify(t('session.tooMany'), 'error')
1385
+ return
1386
+ }
1372
1387
  const id = newPtySessionId()
1373
1388
  ptyActiveIds.add(id)
1374
1389
  setSessions((prev) => [...prev, { id, hostAlias: host.host }])
@@ -1396,6 +1411,7 @@ window.__ModuleLoader__.load({
1396
1411
  }
1397
1412
 
1398
1413
  function clearSession(id) {
1414
+ clearPtySession(id)
1399
1415
  setClearSignals((prev) => ({ ...prev, [id]: (prev[id] ?? 0) + 1 }))
1400
1416
  }
1401
1417
 
@@ -1578,11 +1594,15 @@ window.__ModuleLoader__.load({
1578
1594
  return
1579
1595
  }
1580
1596
  if (editor) {
1581
- setEditor(null)
1597
+ const guard = editorCloseGuard.current
1598
+ if (typeof guard === 'function') guard()
1599
+ else setEditor(null)
1582
1600
  return
1583
1601
  }
1584
1602
  if (groupsOpen) {
1585
- setGroupsOpen(false)
1603
+ const guard = groupsCloseGuard.current
1604
+ if (typeof guard === 'function') guard()
1605
+ else setGroupsOpen(false)
1586
1606
  return
1587
1607
  }
1588
1608
  if (drawer) setDrawer(null)
@@ -1674,6 +1694,8 @@ window.__ModuleLoader__.load({
1674
1694
  setFloating,
1675
1695
  setEditor,
1676
1696
  setDrawer,
1697
+ editorCloseGuard,
1698
+ groupsCloseGuard,
1677
1699
  treeWidth,
1678
1700
  setTreeWidth,
1679
1701
  sessions,
@@ -1928,7 +1950,7 @@ window.__ModuleLoader__.load({
1928
1950
  'div',
1929
1951
  { className: 'ssh-loadingPage' },
1930
1952
  h(Spinner, { size: 22 }),
1931
- h('span', null, t('state.loading')),
1953
+ h('span', null, sessions.length > 0 ? t('session.restoring') : t('state.loading')),
1932
1954
  )
1933
1955
  : !snapshot && error
1934
1956
  ? h(
@@ -1945,7 +1967,7 @@ window.__ModuleLoader__.load({
1945
1967
  : effectiveTab === 'batch'
1946
1968
  ? h(BatchTab, { model })
1947
1969
  : h(SettingsTab, { model }),
1948
- sessions.length > 0
1970
+ snapshot && sessions.length > 0
1949
1971
  ? h(
1950
1972
  'div',
1951
1973
  { className: 'ssh-sessionLayer', style: { display: effectiveTab === 'sessions' ? 'flex' : 'none' } },
@@ -2394,7 +2416,7 @@ window.__ModuleLoader__.load({
2394
2416
  h(
2395
2417
  'div',
2396
2418
  { className: 'ssh-cell ssh-colAddress' },
2397
- h('span', { className: 'ssh-address', title: hostAddress(host) }, hostAddress(host)),
2419
+ h('span', { className: 'ssh-address', title: hostAddress(host) }, hostTarget(host)),
2398
2420
  h(
2399
2421
  'span',
2400
2422
  { className: 'ssh-inlineActions' },
@@ -2503,9 +2525,11 @@ window.__ModuleLoader__.load({
2503
2525
  observer.observe(node)
2504
2526
  return () => observer.disconnect()
2505
2527
  }, [])
2528
+ const hasInfo = visible.some((host) => Boolean(host.system || host.cpuCores || host.memoryGB || host.diskGB))
2529
+ const hasNotes = visible.some((host) => Boolean(host.note))
2506
2530
  const effective = {
2507
- info: columns.info && tableWidth >= 860,
2508
- notes: columns.notes && tableWidth >= 1000,
2531
+ info: columns.info && hasInfo && tableWidth >= 900,
2532
+ notes: columns.notes && hasNotes && tableWidth >= 1100,
2509
2533
  }
2510
2534
  const style = { gridTemplateColumns: tableTracks(effective) }
2511
2535
  const visibleAliases = visible.map((host) => host.host)
@@ -2741,7 +2765,7 @@ window.__ModuleLoader__.load({
2741
2765
  'div',
2742
2766
  { className: 'ssh-sessionActions' },
2743
2767
  activeHost
2744
- ? h('span', { className: 'ssh-sessionAddress', title: hostAddress(activeHost) }, hostAddress(activeHost))
2768
+ ? h('span', { className: 'ssh-sessionAddress', title: hostAddress(activeHost) }, hostTarget(activeHost))
2745
2769
  : null,
2746
2770
  activeCredential ? h(Badge, { tone: activeCredential.tone }, activeCredential.label) : null,
2747
2771
  h(
@@ -2853,7 +2877,7 @@ window.__ModuleLoader__.load({
2853
2877
  'div',
2854
2878
  { className: 'ssh-drawerTitles' },
2855
2879
  h('div', { className: 'ssh-drawerTitle', title: hostName(host) }, hostName(host)),
2856
- h('div', { className: 'ssh-drawerSub' }, hostAddress(host)),
2880
+ h('div', { className: 'ssh-drawerSub' }, hostTarget(host)),
2857
2881
  ),
2858
2882
  h(
2859
2883
  Badge,
@@ -3031,11 +3055,73 @@ window.__ModuleLoader__.load({
3031
3055
  }
3032
3056
  }
3033
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
+
3034
3118
  const PTY_EVENTS_ROUTE = '/dsh-ssh-manage/pty/events'
3035
3119
  const PTY_ATTACH_ROUTE = '/dsh-ssh-manage/pty/attach'
3120
+ const PTY_CLEAR_ROUTE = '/dsh-ssh-manage/pty/clear'
3036
3121
  const ptyStream = { source: null, ready: false, promise: null, listeners: new Map() }
3037
3122
  const ptyActiveIds = new Set()
3038
3123
  const ptyClosedIds = new Set()
3124
+ const MAX_TERMINAL_SESSIONS = 16
3039
3125
  const ptyStreamLostHandlers = new Set()
3040
3126
 
3041
3127
  function closePtySession(id) {
@@ -3057,13 +3143,23 @@ window.__ModuleLoader__.load({
3057
3143
  return () => ptyStreamLostHandlers.delete(handler)
3058
3144
  }
3059
3145
 
3060
- 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
3061
3150
  if (ptyStream.source) {
3062
3151
  try { ptyStream.source.close() } catch {}
3063
3152
  }
3064
3153
  ptyStream.source = null
3065
3154
  ptyStream.ready = false
3066
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
3067
3163
  for (const handler of [...ptyStreamLostHandlers]) {
3068
3164
  try { handler() } catch {}
3069
3165
  }
@@ -3077,8 +3173,18 @@ window.__ModuleLoader__.load({
3077
3173
  }
3078
3174
 
3079
3175
  function ensurePtyStream() {
3080
- if (ptyStream.ready && ptyStream.source && ptyStream.source.readyState === 1) return Promise.resolve()
3081
- 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
+ }
3082
3188
  if (ptyStream.promise) return ptyStream.promise
3083
3189
  ptyStream.promise = new Promise((resolve, reject) => {
3084
3190
  let settled = false
@@ -3092,14 +3198,17 @@ window.__ModuleLoader__.load({
3092
3198
  ptyStream.source = null
3093
3199
  ptyStream.ready = false
3094
3200
  ptyStream.promise = null
3201
+ ptyStream.reject = null
3095
3202
  reject(error)
3096
3203
  }
3097
3204
  ptyStream.source = source
3205
+ ptyStream.reject = reject
3098
3206
  timer = setTimeout(() => fail(new Error('Terminal event stream timed out')), 10_000)
3099
3207
  source.addEventListener('open', () => {
3100
3208
  if (settled) return
3101
3209
  settled = true
3102
3210
  clearTimeout(timer)
3211
+ ptyStream.reject = null
3103
3212
  ptyStream.ready = true
3104
3213
  resolve()
3105
3214
  })
@@ -3153,6 +3262,11 @@ window.__ModuleLoader__.load({
3153
3262
  }
3154
3263
  }
3155
3264
 
3265
+ function clearPtySession(id) {
3266
+ if (!id) return
3267
+ void ptyPost(PTY_CLEAR_ROUTE, { id }, 15_000).catch(() => {})
3268
+ }
3269
+
3156
3270
  function decodeBase64(text) {
3157
3271
  const binary = atob(String(text ?? ''))
3158
3272
  const bytes = new Uint8Array(binary.length)
@@ -3171,6 +3285,8 @@ window.__ModuleLoader__.load({
3171
3285
  const [status, setStatus] = React.useState('loading')
3172
3286
  const [message, setMessage] = React.useState('')
3173
3287
  const [retryToken, setRetryToken] = React.useState(0)
3288
+ const clearRequested = React.useRef(false)
3289
+ const syncedRef = React.useRef(false)
3174
3290
 
3175
3291
  React.useEffect(() => {
3176
3292
  activeRef.current = active === true
@@ -3186,7 +3302,12 @@ window.__ModuleLoader__.load({
3186
3302
  }, [active])
3187
3303
 
3188
3304
  React.useEffect(() => {
3189
- 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
3190
3311
  try {
3191
3312
  termRef.current.clear()
3192
3313
  termRef.current.write('\x1b[H\x1b[2J')
@@ -3209,6 +3330,8 @@ window.__ModuleLoader__.load({
3209
3330
  let opened = false
3210
3331
  let inputChain = Promise.resolve()
3211
3332
  let streamLost = null
3333
+ let themeUnsub = null
3334
+ syncedRef.current = false
3212
3335
  setStatus('loading')
3213
3336
  setMessage('')
3214
3337
 
@@ -3270,6 +3393,10 @@ window.__ModuleLoader__.load({
3270
3393
  termRef.current = term
3271
3394
  fitRef.current = fit
3272
3395
  try { fit.fit() } catch {}
3396
+ themeUnsub = subscribeTerminalTheme(() => {
3397
+ if (removed || !term) return
3398
+ try { term.options.theme = terminalThemeFor(container) } catch {}
3399
+ })
3273
3400
  term.onData((data) => {
3274
3401
  const id = sessionKey
3275
3402
  if (!id || !data || !opened) return
@@ -3319,6 +3446,14 @@ window.__ModuleLoader__.load({
3319
3446
  let lastSeq = 0
3320
3447
  const pendingEvents = []
3321
3448
 
3449
+ const clearTerminal = () => {
3450
+ if (!term) return
3451
+ try {
3452
+ term.clear()
3453
+ term.write('\x1b[H\x1b[2J')
3454
+ } catch {}
3455
+ }
3456
+
3322
3457
  const applyOutput = (event) => {
3323
3458
  const seq = Number(event.seq)
3324
3459
  if (Number.isFinite(seq)) {
@@ -3332,6 +3467,9 @@ window.__ModuleLoader__.load({
3332
3467
  if (event.kind === 'output') {
3333
3468
  if (!synced) pendingEvents.push(event)
3334
3469
  else applyOutput(event)
3470
+ } else if (event.kind === 'clear') {
3471
+ if (!synced) pendingEvents.push(event)
3472
+ else clearTerminal()
3335
3473
  } else if (event.kind === 'exit') {
3336
3474
  if (!synced) {
3337
3475
  pendingEvents.push(event)
@@ -3378,18 +3516,23 @@ window.__ModuleLoader__.load({
3378
3516
  closePtySession(id)
3379
3517
  return
3380
3518
  }
3381
- if (removed) return
3382
- 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) {
3383
3525
  try { term.write(decodeBase64(value.output)) } catch {}
3384
3526
  }
3385
3527
  const seq = Number(value?.seq)
3386
3528
  if (Number.isFinite(seq)) lastSeq = seq
3387
3529
  synced = true
3388
3530
  for (const event of pendingEvents.splice(0)) {
3389
- if (finished) break
3531
+ if (finished || stopped) break
3390
3532
  handleEvent(event)
3391
3533
  }
3392
- if (finished) return
3534
+ if (finished || stopped || removed) return
3535
+ syncedRef.current = true
3393
3536
  opened = true
3394
3537
  setStatus('ready')
3395
3538
  setMessage('')
@@ -3414,6 +3557,7 @@ window.__ModuleLoader__.load({
3414
3557
 
3415
3558
  return () => {
3416
3559
  removed = true
3560
+ if (themeUnsub) themeUnsub()
3417
3561
  if (streamLost) streamLost()
3418
3562
  if (resizeObserver) resizeObserver.disconnect()
3419
3563
  teardown()
@@ -3801,6 +3945,28 @@ window.__ModuleLoader__.load({
3801
3945
  setDraft((prev) => ({ ...prev, [field]: value }))
3802
3946
  }
3803
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
+
3804
3970
  async function submit() {
3805
3971
  const alias = draft.host.trim()
3806
3972
  const hostname = draft.hostname.trim()
@@ -3856,7 +4022,7 @@ window.__ModuleLoader__.load({
3856
4022
  t('editor.delete'),
3857
4023
  )
3858
4024
  : h('span', { key: 'spacer', className: 'ssh-toolSpacer' }),
3859
- 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')),
3860
4026
  h(
3861
4027
  Button,
3862
4028
  { key: 'save', variant: 'primary', onClick: () => void submit(), disabled: busy },
@@ -3873,7 +4039,7 @@ window.__ModuleLoader__.load({
3873
4039
  width: 700,
3874
4040
  icon: h(IconTerminal, { size: 16 }),
3875
4041
  closeLabel: t('common.close'),
3876
- onClose: () => model.setEditor(null),
4042
+ onClose: requestClose,
3877
4043
  footer,
3878
4044
  },
3879
4045
  h(
@@ -3996,6 +4162,17 @@ window.__ModuleLoader__.load({
3996
4162
  const groups = buildGroups(hosts, t).filter((group) => group.key !== '')
3997
4163
  const [drafts, setDrafts] = React.useState(() => Object.fromEntries(groups.map((group) => [group.key, group.key])))
3998
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
+ })
3999
4176
 
4000
4177
  async function commit(name) {
4001
4178
  const next = String(drafts[name] ?? '').trim()
@@ -4012,8 +4189,8 @@ window.__ModuleLoader__.load({
4012
4189
  width: 560,
4013
4190
  icon: h(IconFolder, { size: 16 }),
4014
4191
  closeLabel: t('common.close'),
4015
- onClose: () => model.setGroupsOpen(false),
4016
- 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'))],
4017
4194
  },
4018
4195
  groups.length === 0
4019
4196
  ? h('div', { className: 'ssh-sectionHint' }, t('groups.empty'))
@@ -4204,7 +4381,7 @@ window.__ModuleLoader__.load({
4204
4381
  label: t('table.select'),
4205
4382
  }),
4206
4383
  h('span', { className: 'ssh-targetName' }, hostName(host)),
4207
- h('span', { className: 'ssh-targetAddr' }, hostAddress(host)),
4384
+ h('span', { className: 'ssh-targetAddr' }, hostTarget(host)),
4208
4385
  h('span', { className: 'ssh-targetCred' }, h(Badge, { tone: credential.tone }, credential.label)),
4209
4386
  )
4210
4387
  }),
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.0",
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,