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 +4 -4
- package/README.md +4 -4
- package/client.js +185 -16
- package/index.js +70 -15
- package/lib/metadata.js +28 -11
- package/lib/ssh-config.js +15 -2
- package/lib/storage.js +58 -11
- package/lib/vault.js +30 -12
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3089
|
-
|
|
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
|
|
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 (
|
|
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:
|
|
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:
|
|
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:
|
|
4024
|
-
footer: [h(Button, { key: 'close', onClick:
|
|
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,
|
|
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, ...(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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) {
|