dsh-remote-workspaces 0.1.0 → 0.2.0
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/package.json +2 -2
- package/src/client.js +147 -40
- package/src/index.js +93 -23
- package/src/registry.js +18 -4
- package/src/search.js +72 -8
- package/src/shell-exec.js +42 -1
- package/src/transport.js +348 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-workspaces",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Open folders on remote hosts over SSH as DeepSeek Harness workspaces — a non-invasive DSH bundle.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"cordis.patch.yml"
|
|
22
22
|
],
|
|
23
23
|
"scripts": {
|
|
24
|
-
"test": "node test/unit-anchor-path.mjs && node test/unit-secrets-encryption.mjs && node test/unit-fs.mjs && node test/unit-shell.mjs && node test/unit-routing.mjs && node test/unit-remote-policy.mjs && node test/unit-search-registration.mjs",
|
|
24
|
+
"test": "node test/unit-anchor-path.mjs && node test/unit-secrets-encryption.mjs && node test/unit-fs.mjs && node test/unit-shell.mjs && node test/unit-routing.mjs && node test/unit-remote-policy.mjs && node test/unit-search-registration.mjs && node test/unit-win-remote.mjs && node test/unit-registry-os.mjs",
|
|
25
25
|
"test:integration": "node test/integration-remote.mjs"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
package/src/client.js
CHANGED
|
@@ -75,15 +75,81 @@ window.__ModuleLoader__.load({
|
|
|
75
75
|
var exports = module.exports
|
|
76
76
|
var React = require('react')
|
|
77
77
|
|
|
78
|
-
var sectionStyle = { padding: 16, fontSize: 14, lineHeight: 1.6, maxWidth: 820 }
|
|
79
|
-
var labelStyle = { color: '
|
|
78
|
+
var sectionStyle = { padding: 16, fontSize: 14, lineHeight: 1.6, maxWidth: 820, color: 'inherit' }
|
|
79
|
+
var labelStyle = { color: '#8b8f98', margin: 0, fontSize: 12.5 }
|
|
80
80
|
var monoStyle = { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 12.5 }
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
var
|
|
86
|
-
|
|
81
|
+
// Control styling mirrors the "网关接入 → 查询状态" button (plugins/
|
|
82
|
+
// dsh-gateway-agent): neutral translucent surfaces + `color: inherit` so the
|
|
83
|
+
// text follows the host theme in both dark and light themes. The primary
|
|
84
|
+
// button is a fixed brand fill, matching the reference's S.primary.
|
|
85
|
+
var btnStyle = {
|
|
86
|
+
padding: '7px 14px', fontSize: 13.5, cursor: 'pointer',
|
|
87
|
+
background: 'rgba(127,127,127,0.12)', color: 'inherit',
|
|
88
|
+
border: '1px solid rgba(127,127,127,0.3)', borderRadius: 6, whiteSpace: 'nowrap',
|
|
89
|
+
}
|
|
90
|
+
var mobileBtnStyle = {
|
|
91
|
+
padding: '11px 14px', fontSize: 14, cursor: 'pointer',
|
|
92
|
+
background: 'rgba(127,127,127,0.12)', color: 'inherit',
|
|
93
|
+
border: '1px solid rgba(127,127,127,0.3)', borderRadius: 6, minHeight: 44, whiteSpace: 'nowrap',
|
|
94
|
+
}
|
|
95
|
+
var btnPrim = {
|
|
96
|
+
padding: '7px 14px', fontSize: 13.5, cursor: 'pointer',
|
|
97
|
+
background: '#6e56cf', color: '#fff', border: '1px solid transparent',
|
|
98
|
+
borderRadius: 6, fontWeight: 600, whiteSpace: 'nowrap',
|
|
99
|
+
}
|
|
100
|
+
var btnPrimMob = {
|
|
101
|
+
padding: '11px 14px', fontSize: 14, cursor: 'pointer',
|
|
102
|
+
background: '#6e56cf', color: '#fff', border: '1px solid transparent',
|
|
103
|
+
borderRadius: 6, fontWeight: 600, minHeight: 44, whiteSpace: 'nowrap',
|
|
104
|
+
}
|
|
105
|
+
var btnDanger = {
|
|
106
|
+
padding: '7px 14px', fontSize: 13.5, cursor: 'pointer',
|
|
107
|
+
background: '#e5484d', color: '#fff', border: '1px solid transparent',
|
|
108
|
+
borderRadius: 6, fontWeight: 600, whiteSpace: 'nowrap',
|
|
109
|
+
}
|
|
110
|
+
var btnDangerMob = {
|
|
111
|
+
padding: '11px 14px', fontSize: 14, cursor: 'pointer',
|
|
112
|
+
background: '#e5484d', color: '#fff', border: '1px solid transparent',
|
|
113
|
+
borderRadius: 6, fontWeight: 600, minHeight: 44, whiteSpace: 'nowrap',
|
|
114
|
+
}
|
|
115
|
+
var inputStyle = {
|
|
116
|
+
padding: '7px 10px', fontSize: 13.5, width: '100%', boxSizing: 'border-box',
|
|
117
|
+
background: 'rgba(127,127,127,0.08)', color: 'inherit',
|
|
118
|
+
border: '1px solid rgba(127,127,127,0.3)', borderRadius: 6,
|
|
119
|
+
}
|
|
120
|
+
var mobileInputStyle = {
|
|
121
|
+
padding: '9px 12px', fontSize: 16, width: '100%', boxSizing: 'border-box', minHeight: 44,
|
|
122
|
+
background: 'rgba(127,127,127,0.08)', color: 'inherit',
|
|
123
|
+
border: '1px solid rgba(127,127,127,0.3)', borderRadius: 6,
|
|
124
|
+
}
|
|
125
|
+
var mobileSectionStyle = { padding: 12, fontSize: 14, lineHeight: 1.6, color: 'inherit' }
|
|
126
|
+
var dangerColor = '#e5484d'
|
|
127
|
+
var successColor = '#46a758'
|
|
128
|
+
var borderColor = 'rgba(127,127,127,0.3)'
|
|
129
|
+
var modalBg = '#1f1f21'
|
|
130
|
+
var modalText = '#e8e8e8'
|
|
131
|
+
var chipStyle = { display: 'inline-block', padding: '1px 8px', borderRadius: 10, background: 'rgba(127,127,127,0.18)', fontSize: 11.5, lineHeight: '18px', color: 'inherit' }
|
|
132
|
+
|
|
133
|
+
// Detect a narrow (mobile) viewport so the UI can switch to a stacked,
|
|
134
|
+
// larger-touch-target layout. Pure clickable hooks; no CSS build step.
|
|
135
|
+
function useIsMobile() {
|
|
136
|
+
var state = React.useState(false)
|
|
137
|
+
var matches = state[0]
|
|
138
|
+
var setMatches = state[1]
|
|
139
|
+
React.useEffect(function () {
|
|
140
|
+
if (typeof window === 'undefined' || !window.matchMedia) return
|
|
141
|
+
var mq = window.matchMedia('(max-width: 640px)')
|
|
142
|
+
function onChange(e) { setMatches(e.matches) }
|
|
143
|
+
setMatches(mq.matches)
|
|
144
|
+
if (mq.addEventListener) mq.addEventListener('change', onChange)
|
|
145
|
+
else if (mq.addListener) mq.addListener(onChange)
|
|
146
|
+
return function () {
|
|
147
|
+
if (mq.removeEventListener) mq.removeEventListener('change', onChange)
|
|
148
|
+
else if (mq.removeListener) mq.removeListener(onChange)
|
|
149
|
+
}
|
|
150
|
+
}, [])
|
|
151
|
+
return matches
|
|
152
|
+
}
|
|
87
153
|
|
|
88
154
|
function pathBasename(p) {
|
|
89
155
|
if (!p) return ''
|
|
@@ -135,6 +201,7 @@ window.__ModuleLoader__.load({
|
|
|
135
201
|
var deletingState = React.useState(false)
|
|
136
202
|
var deleting = deletingState[0]
|
|
137
203
|
var setDeleting = deletingState[1]
|
|
204
|
+
var isMobile = useIsMobile()
|
|
138
205
|
|
|
139
206
|
React.useEffect(function () {
|
|
140
207
|
var alive = true
|
|
@@ -285,7 +352,7 @@ window.__ModuleLoader__.load({
|
|
|
285
352
|
|
|
286
353
|
return React.createElement(
|
|
287
354
|
'div',
|
|
288
|
-
{ style: sectionStyle },
|
|
355
|
+
{ className: 'dsh-rw-btn', style: isMobile ? mobileSectionStyle : sectionStyle },
|
|
289
356
|
React.createElement('div', { style: { fontWeight: 600, fontSize: 16, marginBottom: 8 } }, '远程工作区'),
|
|
290
357
|
React.createElement('p', { style: { margin: '0 0 12px' } },
|
|
291
358
|
'管理 SSH 主机与已打开的远程工作区。添加远程工作区请在侧边栏「添加工作区」里选「远程目录」。'),
|
|
@@ -295,9 +362,9 @@ window.__ModuleLoader__.load({
|
|
|
295
362
|
error !== null
|
|
296
363
|
? React.createElement('p', { style: { color: dangerColor, margin: '0 0 12px' } }, error)
|
|
297
364
|
: null,
|
|
298
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, marginBottom: 12 } },
|
|
299
|
-
React.createElement('button', { type: 'button', onClick: openNew, disabled: !remote, style:
|
|
300
|
-
React.createElement('button', { type: 'button', onClick: toggleAliases, disabled: !remote, style: btnStyle }, '从 ~/.ssh/config 导入'),
|
|
365
|
+
React.createElement('div', { style: Object.assign({ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap' }, isMobile ? { flexDirection: 'column' } : {}) },
|
|
366
|
+
React.createElement('button', { type: 'button', onClick: openNew, disabled: !remote, style: Object.assign({}, btnPrim, isMobile ? btnPrimMob : {}) }, '添加主机'),
|
|
367
|
+
React.createElement('button', { type: 'button', onClick: toggleAliases, disabled: !remote, style: Object.assign({}, btnStyle, isMobile ? mobileBtnStyle : {}) }, '从 ~/.ssh/config 导入'),
|
|
301
368
|
),
|
|
302
369
|
showAliases
|
|
303
370
|
? React.createElement('div', { style: { border: '1px solid ' + borderColor, borderRadius: 6, padding: 10, marginBottom: 12 } },
|
|
@@ -334,18 +401,19 @@ window.__ModuleLoader__.load({
|
|
|
334
401
|
function () { doTest(machine) },
|
|
335
402
|
function () { openEdit(machine) },
|
|
336
403
|
function () { doDelete(machine) },
|
|
404
|
+
isMobile,
|
|
337
405
|
)
|
|
338
406
|
}),
|
|
339
407
|
),
|
|
340
408
|
deleteTarget !== null
|
|
341
|
-
? React.createElement('div', { style: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 } },
|
|
342
|
-
React.createElement('div', { style: { background:
|
|
409
|
+
? React.createElement('div', { style: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000, padding: 16 } },
|
|
410
|
+
React.createElement('div', { style: { background: modalBg, border: '1px solid ' + borderColor, borderRadius: 8, padding: isMobile ? 14 : 16, width: 440, maxWidth: '100%', color: modalText } },
|
|
343
411
|
React.createElement('div', { style: { fontWeight: 600, fontSize: 15, marginBottom: 10 } }, '删除主机'),
|
|
344
412
|
React.createElement('p', { style: { margin: '0 0 12px', lineHeight: 1.6 } },
|
|
345
413
|
'确定删除主机「' + (deleteTarget.alias || deleteTarget.host) + '」吗?(远端数据不受影响)'),
|
|
346
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, justifyContent: 'flex-end' } },
|
|
347
|
-
React.createElement('button', { type: 'button', onClick: closeDelete, disabled: deleting, style: btnStyle }, '取消'),
|
|
348
|
-
React.createElement('button', { type: 'button', onClick: confirmDelete, disabled: deleting, style:
|
|
414
|
+
React.createElement('div', { style: Object.assign({ display: 'flex', gap: 8, justifyContent: 'flex-end' }, isMobile ? { flexDirection: 'column' } : {}) },
|
|
415
|
+
React.createElement('button', { type: 'button', onClick: closeDelete, disabled: deleting, style: Object.assign({}, btnStyle, isMobile ? mobileBtnStyle : {}) }, '取消'),
|
|
416
|
+
React.createElement('button', { type: 'button', onClick: confirmDelete, disabled: deleting, style: isMobile ? btnDangerMob : btnDanger }, deleting ? '删除中…' : '删除'),
|
|
349
417
|
),
|
|
350
418
|
),
|
|
351
419
|
)
|
|
@@ -353,22 +421,33 @@ window.__ModuleLoader__.load({
|
|
|
353
421
|
)
|
|
354
422
|
}
|
|
355
423
|
|
|
356
|
-
function MachineRow(machine, result, open, onToggle, onTest, onEdit, onDelete) {
|
|
424
|
+
function MachineRow(machine, result, open, onToggle, onTest, onEdit, onDelete, isMobile) {
|
|
357
425
|
var summary = [machine.alias || '(未命名)']
|
|
358
426
|
if (machine.host) summary.push(machine.host)
|
|
359
427
|
if (machine.user) summary.push('@' + machine.user)
|
|
360
428
|
if (machine.port) summary.push(':' + machine.port)
|
|
429
|
+
var mobileBtn = isMobile ? Object.assign({}, mobileBtnStyle) : btnStyle
|
|
430
|
+
var dangerBtn = isMobile ? btnDangerMob : btnDanger
|
|
431
|
+
function action(label, handler, style) {
|
|
432
|
+
return React.createElement('button', { type: 'button', onClick: function (e) { e.stopPropagation(); handler() }, style: style }, label)
|
|
433
|
+
}
|
|
434
|
+
var testBtn = action('测试连接', onTest, mobileBtn)
|
|
435
|
+
var editBtn = action('编辑', onEdit, mobileBtn)
|
|
436
|
+
var deleteBtn = action('删除', onDelete, dangerBtn)
|
|
361
437
|
return React.createElement(
|
|
362
438
|
'div',
|
|
363
439
|
{ style: { borderTop: '1px solid ' + borderColor, padding: '8px 0' } },
|
|
364
440
|
React.createElement('div', { onClick: onToggle, style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' } },
|
|
365
|
-
React.createElement('span', { style: { color: '
|
|
441
|
+
React.createElement('span', { style: { color: '#8b8f98', width: 16, flexShrink: 0 } }, open ? '▾' : '▸'),
|
|
366
442
|
React.createElement('span', { style: Object.assign({}, monoStyle, { fontWeight: 600, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }) }, summary.join(' ')),
|
|
367
443
|
React.createElement('span', { style: { flex: 1 } }),
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
444
|
+
isMobile ? null : testBtn,
|
|
445
|
+
isMobile ? null : editBtn,
|
|
446
|
+
isMobile ? null : deleteBtn,
|
|
371
447
|
),
|
|
448
|
+
isMobile
|
|
449
|
+
? React.createElement('div', { style: { display: 'flex', gap: 8, marginTop: 6, paddingLeft: 24, flexWrap: 'wrap' } }, testBtn, editBtn, deleteBtn)
|
|
450
|
+
: null,
|
|
372
451
|
React.createElement('div', { style: { display: 'flex', gap: 6, marginTop: 4, flexWrap: 'wrap', paddingLeft: 24 } },
|
|
373
452
|
machine.identityFile
|
|
374
453
|
? React.createElement('span', { title: '私钥:' + machine.identityFile, style: chipStyle }, '私钥 ' + pathBasename(machine.identityFile))
|
|
@@ -403,6 +482,7 @@ window.__ModuleLoader__.load({
|
|
|
403
482
|
})
|
|
404
483
|
var values = valuesState[0]
|
|
405
484
|
var setValues = valuesState[1]
|
|
485
|
+
var isMobile = useIsMobile()
|
|
406
486
|
|
|
407
487
|
function set(field) {
|
|
408
488
|
return function (e) {
|
|
@@ -420,13 +500,15 @@ window.__ModuleLoader__.load({
|
|
|
420
500
|
function field(label, name, type, placeholder) {
|
|
421
501
|
return React.createElement('div', { style: { marginBottom: 8 } },
|
|
422
502
|
React.createElement('div', { style: labelStyle }, label),
|
|
423
|
-
React.createElement('input', { type: type || 'text', value: values[name], onChange: set(name), placeholder: placeholder || '', style: inputStyle }),
|
|
503
|
+
React.createElement('input', { type: type || 'text', value: values[name], onChange: set(name), placeholder: placeholder || '', style: isMobile ? mobileInputStyle : inputStyle }),
|
|
424
504
|
)
|
|
425
505
|
}
|
|
426
506
|
|
|
507
|
+
var formBtn = isMobile ? mobileBtnStyle : btnStyle
|
|
508
|
+
var formBtnPrim = isMobile ? btnPrimMob : btnPrim
|
|
427
509
|
return React.createElement(
|
|
428
510
|
'form',
|
|
429
|
-
{ onSubmit: submit, style: { border: '1px solid ' + borderColor, borderRadius: 6, padding: 12, marginBottom: 12 } },
|
|
511
|
+
{ onSubmit: submit, style: { border: '1px solid ' + borderColor, borderRadius: 6, padding: isMobile ? 10 : 12, marginBottom: 12 } },
|
|
430
512
|
React.createElement('div', { style: { fontWeight: 600, marginBottom: 8 } }, props.form.isNew ? '添加主机' : '编辑主机'),
|
|
431
513
|
field('别名(alias)', 'alias', 'text', '如 dev'),
|
|
432
514
|
field('主机地址(host)', 'host', 'text', '如 192.168.1.10 或 example.com'),
|
|
@@ -435,9 +517,9 @@ window.__ModuleLoader__.load({
|
|
|
435
517
|
field('私钥路径(identityFile)', 'identityFile', 'text', '如 ~/.ssh/id_rsa,留空用默认密钥'),
|
|
436
518
|
field('密码(password)', 'password', 'password', props.form.isNew ? '可选' : '留空保持不变,输入则替换'),
|
|
437
519
|
field('私钥口令(passphrase)', 'passphrase', 'password', props.form.isNew ? '可选' : '留空保持不变'),
|
|
438
|
-
React.createElement('div', { style: { display: 'flex', gap: 8, marginTop: 4 } },
|
|
439
|
-
React.createElement('button', { type: 'submit', style:
|
|
440
|
-
React.createElement('button', { type: 'button', onClick: props.onCancel, style:
|
|
520
|
+
React.createElement('div', { style: Object.assign({ display: 'flex', gap: 8, marginTop: 4 }, isMobile ? { flexDirection: 'column' } : {}) },
|
|
521
|
+
React.createElement('button', { type: 'submit', style: formBtnPrim }, '保存'),
|
|
522
|
+
React.createElement('button', { type: 'button', onClick: props.onCancel, style: formBtn }, '取消'),
|
|
441
523
|
),
|
|
442
524
|
)
|
|
443
525
|
}
|
|
@@ -477,7 +559,7 @@ window.__ModuleLoader__.load({
|
|
|
477
559
|
background: hover ? 'rgba(127,127,127,0.16)' : 'transparent',
|
|
478
560
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
479
561
|
fontSize: 13,
|
|
480
|
-
color: entry.dir ? '
|
|
562
|
+
color: entry.dir ? 'inherit' : 'rgba(232,232,232,0.55)',
|
|
481
563
|
}
|
|
482
564
|
return React.createElement(
|
|
483
565
|
'div',
|
|
@@ -490,7 +572,7 @@ window.__ModuleLoader__.load({
|
|
|
490
572
|
},
|
|
491
573
|
React.createElement('span', { style: { width: 18, textAlign: 'center', flexShrink: 0 } }, entry.dir ? '📁' : '📄'),
|
|
492
574
|
React.createElement('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, entry.name),
|
|
493
|
-
entry.dir ? React.createElement('span', { style: { color: '
|
|
575
|
+
entry.dir ? React.createElement('span', { style: { color: '#8b8f98', flexShrink: 0 } }, '/') : null,
|
|
494
576
|
)
|
|
495
577
|
}
|
|
496
578
|
|
|
@@ -527,6 +609,7 @@ window.__ModuleLoader__.load({
|
|
|
527
609
|
var pathInputState = React.useState('')
|
|
528
610
|
var pathInput = pathInputState[0]
|
|
529
611
|
var setPathInput = pathInputState[1]
|
|
612
|
+
var isMobile = useIsMobile()
|
|
530
613
|
|
|
531
614
|
// Reset per open edge, then load machines.
|
|
532
615
|
React.useEffect(function () {
|
|
@@ -636,20 +719,20 @@ window.__ModuleLoader__.load({
|
|
|
636
719
|
{ style: { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10000 } },
|
|
637
720
|
React.createElement(
|
|
638
721
|
'div',
|
|
639
|
-
{ style: { background:
|
|
722
|
+
{ className: 'dsh-rw-btn', style: { background: modalBg, border: '1px solid ' + borderColor, borderRadius: 8, padding: isMobile ? 14 : 16, width: 560, maxWidth: '92vw', maxHeight: '82vh', overflow: 'auto', color: modalText } },
|
|
640
723
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', marginBottom: 12 } },
|
|
641
724
|
React.createElement('span', { style: { fontWeight: 600, fontSize: 15 } }, '打开文件夹'),
|
|
642
725
|
React.createElement('span', { style: { flex: 1 } }),
|
|
643
|
-
React.createElement('button', { type: 'button', onClick: onCancel, style: btnStyle }, '取消'),
|
|
726
|
+
React.createElement('button', { type: 'button', onClick: onCancel, style: isMobile ? mobileBtnStyle : btnStyle }, '取消'),
|
|
644
727
|
),
|
|
645
728
|
React.createElement('div', { style: { display: 'flex', gap: 8, marginBottom: 12 } },
|
|
646
|
-
React.createElement('button', { type: 'button', onClick: function () { setMode('local') }, style: Object.assign({}, btnStyle, mode === 'local' ? { border: '
|
|
647
|
-
React.createElement('button', { type: 'button', onClick: function () { setMode('remote') }, style: Object.assign({}, btnStyle, mode === 'remote' ? { border: '
|
|
729
|
+
React.createElement('button', { type: 'button', onClick: function () { setMode('local') }, style: Object.assign({}, btnStyle, isMobile ? { flex: 1 } : {}, isMobile ? mobileBtnStyle : {}, mode === 'local' ? { background: '#6e56cf', color: '#fff', border: '1px solid transparent' } : {}) }, '本地文件夹'),
|
|
730
|
+
React.createElement('button', { type: 'button', onClick: function () { setMode('remote') }, style: Object.assign({}, btnStyle, isMobile ? { flex: 1 } : {}, isMobile ? mobileBtnStyle : {}, mode === 'remote' ? { background: '#6e56cf', color: '#fff', border: '1px solid transparent' } : {}) }, '远程目录'),
|
|
648
731
|
),
|
|
649
732
|
mode === 'local'
|
|
650
733
|
? React.createElement('div', {},
|
|
651
734
|
React.createElement('p', { style: { margin: '0 0 12px' } }, '在本机打开系统文件夹选择器,选取一个本地目录作为工作区。'),
|
|
652
|
-
React.createElement('button', { type: 'button', onClick: doLocal, disabled: localPicking || busy, style: btnStyle }, localPicking ? '等待选择…' : '选择本地文件夹'),
|
|
735
|
+
React.createElement('button', { type: 'button', onClick: doLocal, disabled: localPicking || busy, style: Object.assign({}, btnStyle, isMobile ? { width: '100%' } : {}, isMobile ? mobileBtnStyle : {}) }, localPicking ? '等待选择…' : '选择本地文件夹'),
|
|
653
736
|
)
|
|
654
737
|
: React.createElement('div', {},
|
|
655
738
|
React.createElement('div', { style: { marginBottom: 8 } }, '选择 SSH 主机(在「设置 → 远程工作区」中配置):'),
|
|
@@ -662,7 +745,7 @@ window.__ModuleLoader__.load({
|
|
|
662
745
|
var m = machines.find(function (x) { return x.id === id })
|
|
663
746
|
if (m) pickMachine(m)
|
|
664
747
|
},
|
|
665
|
-
style: Object.assign({}, inputStyle, { marginBottom: 8 }),
|
|
748
|
+
style: Object.assign({}, isMobile ? mobileInputStyle : inputStyle, { marginBottom: 8 }),
|
|
666
749
|
},
|
|
667
750
|
React.createElement('option', { value: '' }, '选择主机…'),
|
|
668
751
|
machines.map(function (m) {
|
|
@@ -671,7 +754,7 @@ window.__ModuleLoader__.load({
|
|
|
671
754
|
),
|
|
672
755
|
selected
|
|
673
756
|
? React.createElement('div', { style: { border: '1px solid ' + borderColor, borderRadius: 6, padding: 10 } },
|
|
674
|
-
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 } },
|
|
757
|
+
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8, flexWrap: 'wrap' } },
|
|
675
758
|
React.createElement('input', {
|
|
676
759
|
type: 'text',
|
|
677
760
|
value: pathInput,
|
|
@@ -679,10 +762,12 @@ window.__ModuleLoader__.load({
|
|
|
679
762
|
onKeyDown: function (e) { if (e.key === 'Enter') jumpTo() },
|
|
680
763
|
placeholder: '输入绝对路径,或 ~ 回到主目录',
|
|
681
764
|
spellCheck: false,
|
|
682
|
-
style: Object.assign({}, monoStyle,
|
|
765
|
+
style: Object.assign({}, monoStyle, isMobile
|
|
766
|
+
? { flex: '1 1 100%', minWidth: 0, padding: '9px 8px', fontSize: 16, boxSizing: 'border-box' }
|
|
767
|
+
: { flex: 1, minWidth: 0, padding: '5px 8px', fontSize: 13, boxSizing: 'border-box' }),
|
|
683
768
|
}),
|
|
684
|
-
React.createElement('button', { type: 'button', onClick: jumpTo, disabled: !browse || browse.loading, style: btnStyle }, '跳转'),
|
|
685
|
-
React.createElement('button', { type: 'button', onClick: goUp, disabled: !browse || browse.loading || !browse.path || browse.path === '/', style: btnStyle }, '上一级'),
|
|
769
|
+
React.createElement('button', { type: 'button', onClick: jumpTo, disabled: !browse || browse.loading, style: Object.assign({}, btnStyle, isMobile ? { flex: 1 } : {}, isMobile ? mobileBtnStyle : {}) }, '跳转'),
|
|
770
|
+
React.createElement('button', { type: 'button', onClick: goUp, disabled: !browse || browse.loading || !browse.path || browse.path === '/', style: Object.assign({}, btnStyle, isMobile ? { flex: 1 } : {}, isMobile ? mobileBtnStyle : {}) }, '上一级'),
|
|
686
771
|
),
|
|
687
772
|
browse && browse.loading
|
|
688
773
|
? React.createElement('div', { style: labelStyle }, '加载中…')
|
|
@@ -701,7 +786,7 @@ window.__ModuleLoader__.load({
|
|
|
701
786
|
)
|
|
702
787
|
: null,
|
|
703
788
|
React.createElement('div', { style: { marginTop: 10 } },
|
|
704
|
-
React.createElement('button', { type: 'button', onClick: doOpenRemote, disabled: opening || busy || (browse && browse.loading), style:
|
|
789
|
+
React.createElement('button', { type: 'button', onClick: doOpenRemote, disabled: opening || busy || (browse && browse.loading), style: Object.assign({}, btnPrim, isMobile ? { width: '100%' } : {}, isMobile ? btnPrimMob : {}) },
|
|
705
790
|
opening ? '打开中…' : '打开此目录'),
|
|
706
791
|
),
|
|
707
792
|
)
|
|
@@ -714,6 +799,28 @@ window.__ModuleLoader__.load({
|
|
|
714
799
|
exports.inject = ['slots', 'remote', 'uiWorkspace']
|
|
715
800
|
|
|
716
801
|
exports.apply = function apply(ctx) {
|
|
802
|
+
// Inject a small set of theme-aware control styles (hover / focus /
|
|
803
|
+
// disabled). Backgrounds are deliberately left to the theme so buttons
|
|
804
|
+
// adapt to dark and light instead of forcing a hard-coded box.
|
|
805
|
+
if (typeof document !== 'undefined' && document.head && !document.getElementById('dsh-rw-controls')) {
|
|
806
|
+
var styleEl = document.createElement('style')
|
|
807
|
+
styleEl.id = 'dsh-rw-controls'
|
|
808
|
+
styleEl.textContent = [
|
|
809
|
+
'.dsh-rw-btn button:disabled{opacity:.5;cursor:not-allowed}',
|
|
810
|
+
'.dsh-rw-btn button:focus-visible{outline:2px solid #6e56cf;outline-offset:2px}',
|
|
811
|
+
'.dsh-rw-btn input:focus,.dsh-rw-btn select:focus{box-shadow:0 0 0 1px #6e56cf}',
|
|
812
|
+
// The host chooser sits inside the always-dark modal (modalBg #1f1f21,
|
|
813
|
+
// text #e8e8e8), but a native <select> renders its option popup on the
|
|
814
|
+
// OS theme. Without dark color-scheme the light inherited text lands on
|
|
815
|
+
// a light popup and the host list is unreadable. Force the popup dark,
|
|
816
|
+
// and give the options an explicit light-on-dark fallback.
|
|
817
|
+
'.dsh-rw-btn select{color-scheme:dark}',
|
|
818
|
+
'.dsh-rw-btn select option{color:#e8e8e8;background:#1f1f21}',
|
|
819
|
+
'.dsh-rw-btn select option:checked{background:#6e56cf;color:#fff}',
|
|
820
|
+
].join('\n')
|
|
821
|
+
document.head.appendChild(styleEl)
|
|
822
|
+
}
|
|
823
|
+
|
|
717
824
|
var mount = ctx.remote.$mount({ package: PACKAGE, descriptors: INVOCATIONS })
|
|
718
825
|
var getRemote = function () { return ctx.get('remote.' + NAMESPACE) }
|
|
719
826
|
|
package/src/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import { loadMachines, sanitizeMachine, upsertMachine, removeMachine, machineByI
|
|
|
4
4
|
import { ensureAnchor } from './anchor.js'
|
|
5
5
|
import { RoutingFileSystem } from './routing-fs.js'
|
|
6
6
|
import { SshShellExecutor } from './shell-exec.js'
|
|
7
|
-
import { registerAnchor, unregisterAnchor, findByCwd } from './registry.js'
|
|
7
|
+
import { registerAnchor, unregisterAnchor, findByCwd, updateAnchorOs } from './registry.js'
|
|
8
8
|
import { applySearchTools } from './search.js'
|
|
9
9
|
|
|
10
10
|
export { parseSshConfig, expandTilde } from './ssh-config.js'
|
|
@@ -93,6 +93,13 @@ function messageOf(error) {
|
|
|
93
93
|
return error instanceof Error ? error.message : String(error)
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/** Directory flag from an SFTP attrs record (protocol-level, locale-free). */
|
|
97
|
+
function attrsIsDir(st) {
|
|
98
|
+
if (st && typeof st.isDirectory === 'function') return st.isDirectory()
|
|
99
|
+
if (st && typeof st.mode === 'number') return (st.mode & 0o170000) === 0o040000
|
|
100
|
+
return false
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
/**
|
|
97
104
|
* Build an `SshClient` for a remote host, preferring a saved machine's
|
|
98
105
|
* credentials (password/identityFile/passphrase) and falling back to
|
|
@@ -104,31 +111,62 @@ function clientForRemote(host, user, port) {
|
|
|
104
111
|
return machine ? sshClientFor(machine) : clientForHost(host)
|
|
105
112
|
}
|
|
106
113
|
|
|
114
|
+
const OS_BACKFILL_QUEUED = new Set()
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Lazily backfill the `os` profile on an anchor row that predates B4 (kicked
|
|
118
|
+
* once per anchor per process, fire-and-forget). Prompt hints re-read the
|
|
119
|
+
* registry on every render, so the next prompt after the probe sees the OS.
|
|
120
|
+
*/
|
|
121
|
+
function kickOsBackfill(cwd) {
|
|
122
|
+
if (typeof cwd !== 'string' || cwd === '') return
|
|
123
|
+
let hit
|
|
124
|
+
try { hit = findByCwd(cwd) } catch { return }
|
|
125
|
+
if (hit === undefined || (hit.os && hit.os.family !== undefined)) return
|
|
126
|
+
if (OS_BACKFILL_QUEUED.has(hit.anchorPath)) return
|
|
127
|
+
OS_BACKFILL_QUEUED.add(hit.anchorPath)
|
|
128
|
+
void (async () => {
|
|
129
|
+
try {
|
|
130
|
+
const client = clientForRemote(hit.host, hit.user, hit.port)
|
|
131
|
+
const profile = await client.profile()
|
|
132
|
+
if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
|
|
133
|
+
updateAnchorOs(hit.anchorPath, { family: profile.family, os: profile.os, shell: profile.shell })
|
|
134
|
+
}
|
|
135
|
+
} catch { /* keep the row without os */ }
|
|
136
|
+
})()
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
/**
|
|
108
140
|
* Resolve a raw browse path (home, `~`, `~/x`, relative, or absolute) to an
|
|
109
141
|
* absolute remote path so the client can navigate "up" past home to `/` and
|
|
110
|
-
* display a real path.
|
|
142
|
+
* display a real path. Accepts an optional open SFTP facade to reuse across
|
|
143
|
+
* resolve + listing; otherwise opens its own channel. Resolution goes over
|
|
144
|
+
* SFTP (`realpath`), so it works identically on POSIX and Windows targets and
|
|
145
|
+
* never parses localized shell output.
|
|
111
146
|
*/
|
|
112
|
-
async function resolveRemotePath(client, raw) {
|
|
147
|
+
async function resolveRemotePath(client, raw, sharedSftp) {
|
|
113
148
|
const trimmed = raw === undefined || raw === null ? '' : String(raw).trim()
|
|
114
|
-
let
|
|
149
|
+
let owned
|
|
150
|
+
const sftp = async () => {
|
|
151
|
+
if (sharedSftp !== undefined) return sharedSftp
|
|
152
|
+
owned = owned ?? await client.sftp()
|
|
153
|
+
return owned
|
|
154
|
+
}
|
|
115
155
|
try {
|
|
116
156
|
if (trimmed === '' || trimmed === '~' || trimmed === '~/') {
|
|
117
|
-
|
|
118
|
-
return await sftp.realpath('.')
|
|
157
|
+
return await (await sftp()).realpath('.')
|
|
119
158
|
}
|
|
120
159
|
if (trimmed.startsWith('~/')) {
|
|
121
|
-
|
|
122
|
-
const home = String(await
|
|
160
|
+
const s = await sftp()
|
|
161
|
+
const home = String(await s.realpath('.')).replace(/\/+$/, '')
|
|
123
162
|
return `${home}/${trimmed.slice(2)}`
|
|
124
163
|
}
|
|
125
164
|
if (!trimmed.startsWith('/')) {
|
|
126
|
-
|
|
127
|
-
return await sftp.realpath(trimmed)
|
|
165
|
+
return await (await sftp()).realpath(trimmed)
|
|
128
166
|
}
|
|
129
167
|
return trimmed
|
|
130
168
|
} finally {
|
|
131
|
-
if (
|
|
169
|
+
if (owned !== undefined) owned.end()
|
|
132
170
|
}
|
|
133
171
|
}
|
|
134
172
|
|
|
@@ -183,22 +221,35 @@ function remoteWorkspacesService() {
|
|
|
183
221
|
},
|
|
184
222
|
|
|
185
223
|
async listRemoteDir(machine, path) {
|
|
186
|
-
//
|
|
224
|
+
// Pure-SFTP listing (readdir + attrs) — the SAME channel the fs backend
|
|
225
|
+
// uses, so Windows targets work: no `ls`, no localized output parsing,
|
|
226
|
+
// no dependency on the remote's default shell.
|
|
187
227
|
const client = sshClientFor(machine)
|
|
188
|
-
let
|
|
228
|
+
let sftp
|
|
229
|
+
try {
|
|
230
|
+
sftp = await client.sftp()
|
|
231
|
+
} catch (error) {
|
|
232
|
+
return { ok: false, error: `无法建立 SFTP 连接:${messageOf(error)}` }
|
|
233
|
+
}
|
|
189
234
|
try {
|
|
190
|
-
absPath = await resolveRemotePath(client, path)
|
|
235
|
+
const absPath = await resolveRemotePath(client, path, sftp)
|
|
236
|
+
let list
|
|
237
|
+
try {
|
|
238
|
+
list = await sftp.readdir(absPath)
|
|
239
|
+
} catch (error) {
|
|
240
|
+
const missing = error && (error.code === 2 || /no such file|not exist|找不到/i.test(String(error.message)))
|
|
241
|
+
return { ok: false, error: missing ? `目录不存在:${absPath}` : `无法读取目录:${messageOf(error)}` }
|
|
242
|
+
}
|
|
243
|
+
const entries = (Array.isArray(list) ? list : [])
|
|
244
|
+
.filter((e) => e && e.filename !== '.' && e.filename !== '..')
|
|
245
|
+
.map((e) => ({ name: e.filename, dir: attrsIsDir(e.attrs) }))
|
|
246
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
247
|
+
return { ok: true, entries, path: absPath }
|
|
191
248
|
} catch (error) {
|
|
192
249
|
return { ok: false, error: `无法解析目录:${messageOf(error)}` }
|
|
250
|
+
} finally {
|
|
251
|
+
sftp.end()
|
|
193
252
|
}
|
|
194
|
-
const command = absPath === '' ? 'ls -1Ap' : `ls -1Ap ${shellQuote(absPath)}`
|
|
195
|
-
const res = await client.run(command)
|
|
196
|
-
if (!res.ok) return { ok: false, error: (res.stderr ?? '').trim() || res.error || '列出目录失败' }
|
|
197
|
-
const entries = res.stdout
|
|
198
|
-
.split('\n')
|
|
199
|
-
.filter((name) => name !== '')
|
|
200
|
-
.map((name) => ({ name: name.replace(/\/$/, ''), dir: name.endsWith('/') }))
|
|
201
|
-
return { ok: true, entries, path: absPath }
|
|
202
253
|
},
|
|
203
254
|
|
|
204
255
|
/**
|
|
@@ -220,6 +271,16 @@ function remoteWorkspacesService() {
|
|
|
220
271
|
const rel = path === undefined || path === '' ? '.' : path
|
|
221
272
|
const remotePath = await sftp.realpath(rel)
|
|
222
273
|
const anchorPath = ensureAnchor(machine, remotePath)
|
|
274
|
+
// Probe the remote profile once so prompt/dialect hints can declare
|
|
275
|
+
// the actual shell (PowerShell on Windows remotes). Failure to probe
|
|
276
|
+
// only leaves the hint off — never fails the open.
|
|
277
|
+
let os
|
|
278
|
+
try {
|
|
279
|
+
const profile = await client.profile()
|
|
280
|
+
if (profile !== undefined && (profile.family === 'posix' || profile.family === 'windows')) {
|
|
281
|
+
os = { family: profile.family, os: profile.os, shell: profile.shell }
|
|
282
|
+
}
|
|
283
|
+
} catch { /* os stays undefined */ }
|
|
223
284
|
registerAnchor({
|
|
224
285
|
anchorPath,
|
|
225
286
|
machineId: machine?.id,
|
|
@@ -227,6 +288,7 @@ function remoteWorkspacesService() {
|
|
|
227
288
|
port: machine?.port ?? null,
|
|
228
289
|
user: machine?.user ?? null,
|
|
229
290
|
remotePath,
|
|
291
|
+
...(os !== undefined ? { os } : {}),
|
|
230
292
|
})
|
|
231
293
|
return { ok: true, localDir: anchorPath, remotePath }
|
|
232
294
|
} catch (error) {
|
|
@@ -285,6 +347,8 @@ export function apply(ctx) {
|
|
|
285
347
|
if (typeof cwd !== 'string' || cwd === '') return cwd
|
|
286
348
|
const hit = findByCwd(cwd)
|
|
287
349
|
if (hit === undefined) return cwd
|
|
350
|
+
// Side effect: backfill the os profile on rows registered before B4.
|
|
351
|
+
kickOsBackfill(cwd)
|
|
288
352
|
return hit.remoteSubpath === '' ? hit.remotePath : `${hit.remotePath.replace(/\/+$/, '')}/${hit.remoteSubpath}`
|
|
289
353
|
})
|
|
290
354
|
})
|
|
@@ -303,8 +367,14 @@ export function apply(ctx) {
|
|
|
303
367
|
if (typeof cwd !== 'string' || cwd === '') return ''
|
|
304
368
|
const hit = findByCwd(cwd)
|
|
305
369
|
if (hit === undefined) return ''
|
|
370
|
+
// Side effect: backfill the os profile on rows registered before B4.
|
|
371
|
+
kickOsBackfill(cwd)
|
|
306
372
|
const host = hit.user ? `${hit.user}@${hit.host}` : hit.host
|
|
307
|
-
|
|
373
|
+
const base = `Remote workspace over SSH (${host}): file/search tools and shell commands run on the remote host; use relative paths (they route to the remote automatically).`
|
|
374
|
+
if (hit.os?.family === 'windows') {
|
|
375
|
+
return base + ' The remote shell is PowerShell — write PowerShell syntax (built-in aliases like ls/cat/cd/pwd work; bash-only syntax such as && or 2>/dev/null does not).'
|
|
376
|
+
}
|
|
377
|
+
return base
|
|
308
378
|
},
|
|
309
379
|
})
|
|
310
380
|
})
|
package/src/registry.js
CHANGED
|
@@ -34,14 +34,28 @@ function saveAnchors(anchors) {
|
|
|
34
34
|
writeFileSync(anchorsPath(), JSON.stringify(anchors, null, 2) + '\n', 'utf8')
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/** Record an anchor (idempotent by anchorPath). Returns the stored record.
|
|
38
|
-
|
|
37
|
+
/** Record an anchor (idempotent by anchorPath). Returns the stored record.
|
|
38
|
+
* `os` (optional) is the probed remote profile `{ family, os, shell }` used
|
|
39
|
+
* by prompt/dialect hints; older rows without it are backfilled lazily. */
|
|
40
|
+
export function registerAnchor({ anchorPath, machineId, host, port, user, remotePath, os }) {
|
|
39
41
|
const anchors = loadAnchors()
|
|
40
|
-
|
|
42
|
+
const rec = { machineId, host, port, user, remotePath, registeredAt: new Date().toISOString() }
|
|
43
|
+
if (os !== undefined && os !== null) rec.os = os
|
|
44
|
+
anchors[anchorPath] = rec
|
|
41
45
|
saveAnchors(anchors)
|
|
42
46
|
return anchors[anchorPath]
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
/** Backfill (or replace) the probed remote profile on an existing anchor row. */
|
|
50
|
+
export function updateAnchorOs(anchorPath, os) {
|
|
51
|
+
const anchors = loadAnchors()
|
|
52
|
+
const rec = anchors[anchorPath]
|
|
53
|
+
if (rec === undefined) return undefined
|
|
54
|
+
rec.os = os
|
|
55
|
+
saveAnchors(anchors)
|
|
56
|
+
return rec
|
|
57
|
+
}
|
|
58
|
+
|
|
45
59
|
/** Remove one anchor by its local path. */
|
|
46
60
|
export function unregisterAnchor(anchorPath) {
|
|
47
61
|
const anchors = loadAnchors()
|
|
@@ -73,4 +87,4 @@ export function findByCwd(cwd) {
|
|
|
73
87
|
return { ...best, remoteSubpath: rel === '' ? '' : rel.split(sep).join('/') }
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
export default { loadAnchors, registerAnchor, unregisterAnchor, findByCwd, remoteWorkspacesRoot }
|
|
90
|
+
export default { loadAnchors, registerAnchor, unregisterAnchor, updateAnchorOs, findByCwd, remoteWorkspacesRoot }
|
package/src/search.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { posix } from 'node:path'
|
|
14
|
-
import { shellQuote } from './transport.js'
|
|
14
|
+
import { shellQuote, psQuote, toWinPath, profileCacheKey } from './transport.js'
|
|
15
15
|
import { findByCwd } from './registry.js'
|
|
16
16
|
|
|
17
17
|
const GREP_MAX_MATCHES = 250
|
|
@@ -21,6 +21,9 @@ const SEARCH_TIMEOUT_MS = 30_000
|
|
|
21
21
|
const STDERR_MAX_BYTES = 64 * 1024
|
|
22
22
|
const GRACE_MS = 3_000
|
|
23
23
|
|
|
24
|
+
/** One-time rg-presence verdict per Windows target (process lifetime). */
|
|
25
|
+
const RG_PRESENCE_CACHE = new Map()
|
|
26
|
+
|
|
24
27
|
// ---------------------------------------------------------------------------
|
|
25
28
|
// ripgrep argv (mirrors @deepseek-ai/dsh-tool-fs-search)
|
|
26
29
|
// ---------------------------------------------------------------------------
|
|
@@ -108,11 +111,57 @@ async function localRg(deps, argv, cwd, signal) {
|
|
|
108
111
|
if (outcome.exitCode !== 0 && outcome.exitCode !== 1) {
|
|
109
112
|
throw new Error(`grep/glob: rg failed (exit ${outcome.exitCode}): ${stderr?.text ?? ''}`)
|
|
110
113
|
}
|
|
111
|
-
return stdout?.text ?? ''
|
|
114
|
+
return { stdout: stdout?.text ?? '', windows: false }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build the remote `rg` command line for one profile. POSIX keeps the legacy
|
|
119
|
+
* single-quoted form. Windows runs rg.exe inside the per-shell PowerShell
|
|
120
|
+
* script, so EVERY token is PowerShell-single-quoted (patterns with spaces or
|
|
121
|
+
* apostrophes stay one argument) and the search root — the token after `--`,
|
|
122
|
+
* which arrives in the canonical `/X:/…` SFTP form — is converted with
|
|
123
|
+
* `toWinPath` because native rg.exe does not understand a leading-slash drive
|
|
124
|
+
* path. `--path-separator=/` forces forward-slash paths in rg's JSON/file
|
|
125
|
+
* output, matching every other path the plugin surfaces (native rg.exe would
|
|
126
|
+
* otherwise print `src\file.js`). `--regexp`/`--glob` values are pattern text
|
|
127
|
+
* and pass through as-is.
|
|
128
|
+
*/
|
|
129
|
+
export function remoteRgCommand(profile, argv) {
|
|
130
|
+
if (profile !== undefined && profile.family === 'windows') {
|
|
131
|
+
const parts = [psQuote('--path-separator=/')]
|
|
132
|
+
let isRoot = false
|
|
133
|
+
for (const token of argv) {
|
|
134
|
+
parts.push(isRoot ? psQuote(toWinPath(token)) : psQuote(token))
|
|
135
|
+
isRoot = token === '--'
|
|
136
|
+
}
|
|
137
|
+
return `rg ${parts.join(' ')}`
|
|
138
|
+
}
|
|
139
|
+
return ['rg', ...argv].map(shellQuote).join(' ')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Verify ripgrep exists on a Windows target once per process. A missing `rg`
|
|
144
|
+
* must NOT surface as the glue's exit 1 (which the caller reads as "no
|
|
145
|
+
* matches"), so it is detected up front and reported with an install hint.
|
|
146
|
+
*/
|
|
147
|
+
async function ensureRemoteRg(client) {
|
|
148
|
+
const key = profileCacheKey(client)
|
|
149
|
+
let present = RG_PRESENCE_CACHE.get(key)
|
|
150
|
+
if (present === undefined) {
|
|
151
|
+
const probe = await client.run('rg --version')
|
|
152
|
+
present = probe.ok
|
|
153
|
+
RG_PRESENCE_CACHE.set(key, present)
|
|
154
|
+
}
|
|
155
|
+
if (!present) {
|
|
156
|
+
throw new Error('grep/glob: ripgrep was not found on the remote Windows host. Install it first (e.g. winget install BurntSushi.ripgrep.MSVC or scoop install ripgrep) and retry')
|
|
157
|
+
}
|
|
112
158
|
}
|
|
113
159
|
|
|
114
160
|
async function remoteRg(client, argv, remoteCwd, signal) {
|
|
115
|
-
const
|
|
161
|
+
const profile = await client.profile()
|
|
162
|
+
const windows = profile.family === 'windows'
|
|
163
|
+
if (windows) await ensureRemoteRg(client)
|
|
164
|
+
const command = remoteRgCommand(profile, argv)
|
|
116
165
|
const result = await client.execShell(command, {
|
|
117
166
|
cwd: remoteCwd,
|
|
118
167
|
timeoutMs: SEARCH_TIMEOUT_MS,
|
|
@@ -124,7 +173,16 @@ async function remoteRg(client, argv, remoteCwd, signal) {
|
|
|
124
173
|
if (result.exitCode !== 0 && result.exitCode !== 1) {
|
|
125
174
|
throw new Error(`grep/glob: remote rg failed (exit ${result.exitCode}): ${result.stderr.text}`)
|
|
126
175
|
}
|
|
127
|
-
return result.stdout.text
|
|
176
|
+
return { stdout: result.stdout.text, windows }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* rg.exe ignores `--path-separator` for the JSON match records it emits, so
|
|
181
|
+
* grep paths come back with native backslashes on Windows remotes. Normalize
|
|
182
|
+
* to forward slashes so every surfaced path matches the canonical form.
|
|
183
|
+
*/
|
|
184
|
+
function normalizeWindowsPath(value) {
|
|
185
|
+
return String(value).replace(/\\/g, '/')
|
|
128
186
|
}
|
|
129
187
|
|
|
130
188
|
// ---------------------------------------------------------------------------
|
|
@@ -216,8 +274,13 @@ export function createSearchTools(deps) {
|
|
|
216
274
|
...(args.path !== undefined ? { path: args.path } : {}),
|
|
217
275
|
...(args.include !== undefined ? { include: args.include } : {}),
|
|
218
276
|
}
|
|
219
|
-
const stdout = await runRg(exec, grepArgv, input)
|
|
220
|
-
|
|
277
|
+
const { stdout, windows } = await runRg(exec, grepArgv, input)
|
|
278
|
+
const matches = parseGrepMatches(stdout).slice(0, GREP_MAX_MATCHES)
|
|
279
|
+
return {
|
|
280
|
+
matches: windows
|
|
281
|
+
? matches.map((m) => ({ ...m, path: normalizeWindowsPath(m.path) }))
|
|
282
|
+
: matches,
|
|
283
|
+
}
|
|
221
284
|
},
|
|
222
285
|
}
|
|
223
286
|
|
|
@@ -248,8 +311,9 @@ export function createSearchTools(deps) {
|
|
|
248
311
|
},
|
|
249
312
|
async execute(args, exec) {
|
|
250
313
|
const input = { pattern: args.pattern, ...(args.path !== undefined ? { path: args.path } : {}) }
|
|
251
|
-
const stdout = await runRg(exec, globArgv, input)
|
|
252
|
-
|
|
314
|
+
const { stdout, windows } = await runRg(exec, globArgv, input)
|
|
315
|
+
const paths = parseGlobPaths(stdout).slice(0, GLOB_MAX_RESULTS)
|
|
316
|
+
return { paths: windows ? paths.map(normalizeWindowsPath) : paths }
|
|
253
317
|
},
|
|
254
318
|
}
|
|
255
319
|
|
package/src/shell-exec.js
CHANGED
|
@@ -226,6 +226,7 @@ export class SshShellExecutor {
|
|
|
226
226
|
let buffer = ''
|
|
227
227
|
let spawnError
|
|
228
228
|
let pollTimer
|
|
229
|
+
let streamCtl = null
|
|
229
230
|
|
|
230
231
|
const proc = {
|
|
231
232
|
status: 'running',
|
|
@@ -240,10 +241,50 @@ export class SshShellExecutor {
|
|
|
240
241
|
kill() {
|
|
241
242
|
if (proc.status !== 'running') return false
|
|
242
243
|
proc.status = 'killed'
|
|
243
|
-
if (
|
|
244
|
+
if (streamCtl !== null) streamCtl.terminate()
|
|
245
|
+
else if (pid !== null) void client.run(`kill ${pid} 2>/dev/null || true`)
|
|
244
246
|
return true
|
|
245
247
|
},
|
|
246
248
|
done: (async () => {
|
|
249
|
+
const profile = await client.profile()
|
|
250
|
+
if (profile.family === 'windows') {
|
|
251
|
+
// No nohup-style detach exists on Windows remotes, and closing the
|
|
252
|
+
// channel alone does NOT reap the remote tree (verified) - so the
|
|
253
|
+
// job is a long-lived exec stream whose terminate() actively
|
|
254
|
+
// taskkill /T's the remote PID and then closes the channel.
|
|
255
|
+
const ctl = await client.execStream(spec.command, { cwd: path })
|
|
256
|
+
if (proc.status !== 'running') { ctl.terminate(); return }
|
|
257
|
+
streamCtl = ctl
|
|
258
|
+
const merge = () => {
|
|
259
|
+
const outPart = ctl.readOut()
|
|
260
|
+
const errPart = ctl.readErr()
|
|
261
|
+
if (outPart.delta.length > 0) {
|
|
262
|
+
if (buffer.length > 0 && !buffer.endsWith('\n')) buffer += '\n'
|
|
263
|
+
buffer += outPart.delta
|
|
264
|
+
}
|
|
265
|
+
if (errPart.delta.length > 0) {
|
|
266
|
+
if (buffer.length > 0 && !buffer.endsWith('\n')) buffer += '\n'
|
|
267
|
+
buffer += '[stderr]\n' + errPart.delta
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (spec.signal !== undefined) {
|
|
271
|
+
const onAbort = () => { if (streamCtl !== null) streamCtl.terminate() }
|
|
272
|
+
if (spec.signal.aborted) onAbort()
|
|
273
|
+
else spec.signal.addEventListener('abort', onAbort, { once: true })
|
|
274
|
+
}
|
|
275
|
+
const pump = async () => {
|
|
276
|
+
while (proc.status === 'running') {
|
|
277
|
+
merge()
|
|
278
|
+
await new Promise((r) => setTimeout(r, 150))
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
void pump()
|
|
282
|
+
const outcome = await ctl.exit
|
|
283
|
+
merge()
|
|
284
|
+
if (proc.status === 'running') proc.status = 'completed'
|
|
285
|
+
proc.exitCode = typeof outcome.exitCode === 'number' ? outcome.exitCode : null
|
|
286
|
+
return
|
|
287
|
+
}
|
|
247
288
|
const launched = await client.run(launchScript)
|
|
248
289
|
if (!launched.ok) {
|
|
249
290
|
spawnError = new Error((launched.stderr ?? '').trim() || launched.error || 'background spawn failed')
|
package/src/transport.js
CHANGED
|
@@ -12,6 +12,167 @@ export function shellQuote(value) {
|
|
|
12
12
|
return `'${String(value).replace(/'/g, `'\\''`)}'`
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Convert a canonical REMOTE path — as the SFTP `realpath` reports it, which
|
|
17
|
+
* is POSIX-style even on Windows hosts (`/X:/work/demo`) — into the form a
|
|
18
|
+
* Windows shell accepts (`X:/work/demo`). PowerShell understands forward
|
|
19
|
+
* slashes, so only a leading `/X:` drive prefix is stripped; everything else
|
|
20
|
+
* passes through untouched.
|
|
21
|
+
*/
|
|
22
|
+
export function toWinPath(value) {
|
|
23
|
+
const s = String(value ?? '')
|
|
24
|
+
return /^\/[A-Za-z]:/.test(s) ? s.slice(1) : s
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** PowerShell single-quote escaping for a path/string literal. */
|
|
28
|
+
export function psQuote(value) {
|
|
29
|
+
return `'${String(value).replace(/'/g, "''")}'`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Strip PowerShell progress records from a stderr capture. When a nested
|
|
34
|
+
* powershell is launched by a Windows OpenSSH exec channel (e.g. the cmd
|
|
35
|
+
* default-shell wrapper), the engine's first-run module-analysis warm-up emits
|
|
36
|
+
* a `#< CLIXML …</Objs>` progress blob to stderr on every fresh process —
|
|
37
|
+
* real cmdlet errors still arrive as plain text, so removing only CLIXML
|
|
38
|
+
* blocks keeps genuine diagnostics intact.
|
|
39
|
+
*/
|
|
40
|
+
export function stripPsProgressClixml(text) {
|
|
41
|
+
const lines = String(text ?? '').split('\n')
|
|
42
|
+
const out = []
|
|
43
|
+
let inBlob = false
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
if (!inBlob && /#< CLIXML/i.test(line)) {
|
|
46
|
+
inBlob = !/<\/Objs>/i.test(line)
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (inBlob) {
|
|
50
|
+
if (/<\/Objs>/i.test(line)) { inBlob = false; continue }
|
|
51
|
+
// XML record lines are dropped; a non-XML text line ends a truncated
|
|
52
|
+
// blob so genuine diagnostics that follow it are preserved.
|
|
53
|
+
if (!/^\s*</.test(line) && line.trim() !== '') { inBlob = false; out.push(line); continue }
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
out.push(line)
|
|
57
|
+
}
|
|
58
|
+
return out.join('\n')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whole-text CRLF→LF (Windows remote output normalization). */
|
|
62
|
+
export function crlfToLf(text) {
|
|
63
|
+
return String(text ?? '').replace(/\r\n/g, '\n')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Streaming CRLF→LF normalizer that carries a trailing `\r` across chunk
|
|
68
|
+
* boundaries (a `\r\n` pair may be split between two data events). `feed`
|
|
69
|
+
* returns the normalized fragment; call `flush()` at stream end for a lone
|
|
70
|
+
* trailing `\r` (isolated CRs are preserved, not folded).
|
|
71
|
+
*/
|
|
72
|
+
export function createCrlfToLf() {
|
|
73
|
+
let carry = ''
|
|
74
|
+
return {
|
|
75
|
+
feed(text) {
|
|
76
|
+
const s = String(text ?? '')
|
|
77
|
+
let out = carry + s
|
|
78
|
+
carry = ''
|
|
79
|
+
out = out.replace(/\r\n/g, '\n')
|
|
80
|
+
if (out.endsWith('\r')) {
|
|
81
|
+
carry = '\r'
|
|
82
|
+
out = out.slice(0, -1)
|
|
83
|
+
}
|
|
84
|
+
return out
|
|
85
|
+
},
|
|
86
|
+
flush() {
|
|
87
|
+
const rest = carry
|
|
88
|
+
carry = ''
|
|
89
|
+
return rest
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Exit-code glue appended to every Windows remote script so the channel's
|
|
96
|
+
* exit status mirrors what a POSIX shell reports: the last command's result.
|
|
97
|
+
* `exit N` inside the user's own command terminates first and wins.
|
|
98
|
+
*/
|
|
99
|
+
const PS_EXIT_GLUE = 'if ($?) { exit $LASTEXITCODE } else { exit 1 }'
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Remote execution profiles, probed once per target and cached for the
|
|
103
|
+
* process lifetime:
|
|
104
|
+
* { family: 'posix', os: 'linux'|'darwin', shell: 'posix' }
|
|
105
|
+
* { family: 'windows', os: 'windows', shell: 'powershell'|'cmd' }
|
|
106
|
+
* { family: 'unknown', … } — callers fall back to POSIX behaviour.
|
|
107
|
+
*/
|
|
108
|
+
const REMOTE_PROFILE_CACHE = new Map()
|
|
109
|
+
|
|
110
|
+
export function profileCacheKey({ host, user, port }) {
|
|
111
|
+
return `${user ?? ''}@${host ?? ''}:${port ?? 22}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect a target's OS family and default exec shell without parsing
|
|
116
|
+
* localized output: `uname -s` proves a POSIX shell; `cmd /c ver` proves
|
|
117
|
+
* Windows (it runs from cmd, PowerShell and Git Bash defaults alike); a
|
|
118
|
+
* `$PSVersionTable` expression then tells cmd from PowerShell as the default.
|
|
119
|
+
*/
|
|
120
|
+
export async function probeRemoteProfile(client) {
|
|
121
|
+
const uname = await client.run('uname -s')
|
|
122
|
+
if (uname.ok) {
|
|
123
|
+
const os = (uname.stdout ?? '').trim().toLowerCase()
|
|
124
|
+
return { family: 'posix', os: os.startsWith('darwin') ? 'darwin' : 'linux', shell: 'posix' }
|
|
125
|
+
}
|
|
126
|
+
const ver = await client.run('cmd /c ver')
|
|
127
|
+
if (ver.ok && /windows/i.test(ver.stdout ?? '')) {
|
|
128
|
+
const ps = await client.run('$PSVersionTable.PSVersion.ToString()')
|
|
129
|
+
return ps.ok
|
|
130
|
+
? { family: 'windows', os: 'windows', shell: 'powershell' }
|
|
131
|
+
: { family: 'windows', os: 'windows', shell: 'cmd' }
|
|
132
|
+
}
|
|
133
|
+
return { family: 'unknown', os: 'unknown', shell: 'unknown' }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Encode a PowerShell script for quote-safe transport through cmd.exe.
|
|
137
|
+
*
|
|
138
|
+
* `powershell -EncodedCommand` would be ideal (no quoting at all), but a
|
|
139
|
+
* nested -EncodedCommand host serializes BOTH progress and error records to
|
|
140
|
+
* stderr as CLIXML soup. A `-Command` host prints plain text, so instead the
|
|
141
|
+
* script rides inside one fixed, quote-free command line: base64 has no
|
|
142
|
+
* `% ! ^ " $ '` and the wrapper has no `$`, so neither cmd nor a PowerShell
|
|
143
|
+
* outer shell can mangle it, and the inner text is decoded + `iex`'d.
|
|
144
|
+
*/
|
|
145
|
+
export function psCommandEnvelope(script) {
|
|
146
|
+
const b64 = Buffer.from(script, 'utf16le').toString('base64')
|
|
147
|
+
return `powershell -NoProfile -NonInteractive -Command "& { iex ([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${b64}'))) }"`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Build the exec-channel script for one command under a remote profile.
|
|
152
|
+
*
|
|
153
|
+
* POSIX / unknown: unchanged legacy form (`cd 'x' || exit 1` + command).
|
|
154
|
+
*
|
|
155
|
+
* Windows with a PowerShell default shell: the raw script text is executed
|
|
156
|
+
* by the server's default shell directly (cmdlets + aliases like ls/cat/cd
|
|
157
|
+
* make simple commands work), prefixed with a `Set-Location` when a cwd is
|
|
158
|
+
* given and suffixed with the exit-code glue. Windows with a cmd default
|
|
159
|
+
* shell: the same script rides inside a fixed, quote-free
|
|
160
|
+
* `powershell -Command` envelope (base64 + iex), so cmd only ever parses one
|
|
161
|
+
* fixed command line, its exit code is the child's, and stderr stays plain
|
|
162
|
+
* text (no CLIXML records).
|
|
163
|
+
*/
|
|
164
|
+
export function buildExecScript(profile, command, cwd) {
|
|
165
|
+
if (profile === undefined || profile.family !== 'windows') {
|
|
166
|
+
return cwd ? `cd ${shellQuote(cwd)} || exit 1\n${command}` : command
|
|
167
|
+
}
|
|
168
|
+
const script = [
|
|
169
|
+
...(cwd ? [`Set-Location -LiteralPath ${psQuote(toWinPath(cwd))}`] : []),
|
|
170
|
+
command,
|
|
171
|
+
PS_EXIT_GLUE,
|
|
172
|
+
].join('\n')
|
|
173
|
+
return profile.shell === 'cmd' ? psCommandEnvelope(script) : script
|
|
174
|
+
}
|
|
175
|
+
|
|
15
176
|
/** Bounded output collector: keeps the TAIL of a stream, flags truncation. */
|
|
16
177
|
class CapCollector {
|
|
17
178
|
constructor(maxBytes) {
|
|
@@ -193,9 +354,15 @@ export class SshClient {
|
|
|
193
354
|
* bounded (tail-kept) stdout/stderr, stdin, and an abort signal that closes
|
|
194
355
|
* the exec channel (SIGHUP on the remote). Resolves with exitCode/signal,
|
|
195
356
|
* timedOut/aborted first-cause, and `{ text, truncated }` outputs.
|
|
357
|
+
*
|
|
358
|
+
* The command text is executed through the target's default shell. On
|
|
359
|
+
* Windows targets the script is built per detected default shell
|
|
360
|
+
* (`buildExecScript`): raw PowerShell when PowerShell is the default, a
|
|
361
|
+
* quote-safe `powershell -EncodedCommand` wrapper when cmd is.
|
|
196
362
|
*/
|
|
197
363
|
async execShell(command, { cwd, timeoutMs = 60000, stdoutMaxBytes = 64000, stderrMaxBytes = 64000, stdin, signal } = {}) {
|
|
198
|
-
const
|
|
364
|
+
const profile = await this.profile()
|
|
365
|
+
const script = buildExecScript(profile, command, cwd)
|
|
199
366
|
return await new Promise((resolve) => {
|
|
200
367
|
const conn = new Client()
|
|
201
368
|
let settled = false
|
|
@@ -228,14 +395,20 @@ export class SshClient {
|
|
|
228
395
|
s.stderr.on('data', (d) => errc.push(d))
|
|
229
396
|
s.on('close', (code) => {
|
|
230
397
|
const aborted = signal !== undefined && signal.aborted
|
|
398
|
+
const stdout = out.output()
|
|
399
|
+
const stderr = errc.output()
|
|
400
|
+
if (profile.family === 'windows') {
|
|
401
|
+
stderr.text = crlfToLf(stripPsProgressClixml(stderr.text))
|
|
402
|
+
stdout.text = crlfToLf(stdout.text)
|
|
403
|
+
}
|
|
231
404
|
finish({
|
|
232
405
|
ok: true,
|
|
233
406
|
exitCode: code,
|
|
234
407
|
signal: null,
|
|
235
408
|
timedOut: timedOut && !aborted,
|
|
236
409
|
aborted: aborted && !timedOut,
|
|
237
|
-
stdout
|
|
238
|
-
stderr
|
|
410
|
+
stdout,
|
|
411
|
+
stderr,
|
|
239
412
|
})
|
|
240
413
|
})
|
|
241
414
|
if (stdin === undefined) s.end()
|
|
@@ -293,10 +466,165 @@ export class SshClient {
|
|
|
293
466
|
})
|
|
294
467
|
}
|
|
295
468
|
|
|
469
|
+
/**
|
|
470
|
+
* Open a LONG-LIVED exec channel for streaming (used by background/start
|
|
471
|
+
* jobs on Windows remotes, where no nohup-style detach exists and closing
|
|
472
|
+
* the channel alone does NOT reliably reap the remote command tree — a kill
|
|
473
|
+
* must actively `taskkill /T` the remote process first; the channel close
|
|
474
|
+
* is only a fallback).
|
|
475
|
+
*
|
|
476
|
+
* On Windows the remote script is prefixed with a self-reporting PID line
|
|
477
|
+
* (`DWSH_PID=<pid>`), which is parsed out of the first output chunk so the
|
|
478
|
+
* controller can tree-kill the exact process. Resolves a controller:
|
|
479
|
+
* { readOut(), readErr() -> { delta, lossy }, exit: Promise<{exitCode}|{error}>, terminate() }
|
|
480
|
+
*/
|
|
481
|
+
async execStream(command, { cwd, signal, stdoutMaxBytes = 16 * 1024 * 1024, stderrMaxBytes = 16 * 1024 * 1024 } = {}) {
|
|
482
|
+
const profile = await this.profile()
|
|
483
|
+
const windows = profile.family === 'windows'
|
|
484
|
+
const ssh = this
|
|
485
|
+
// The marker runs FIRST inside the same remote shell that owns the exec
|
|
486
|
+
// channel (the raw PowerShell on PS-default hosts, the nested PowerShell
|
|
487
|
+
// inside the -Command envelope on cmd-default hosts), so its $PID is the
|
|
488
|
+
// process whose tree taskkill must reap.
|
|
489
|
+
const markerCommand = windows ? "Write-Output ('DWSH_PID=' + $PID)\n" + command : command
|
|
490
|
+
const script = buildExecScript(profile, markerCommand, cwd)
|
|
491
|
+
return await new Promise((resolve, reject) => {
|
|
492
|
+
const conn = new Client()
|
|
493
|
+
const out = { buf: '', pos: 0, max: stdoutMaxBytes, lossy: false }
|
|
494
|
+
const err = { buf: '', pos: 0, max: stderrMaxBytes, lossy: false }
|
|
495
|
+
let stream
|
|
496
|
+
let settled = false
|
|
497
|
+
let pidResolve
|
|
498
|
+
const pid = new Promise((res) => { pidResolve = res })
|
|
499
|
+
// A stuck launch must not hold terminate() forever.
|
|
500
|
+
const pidTimer = setTimeout(() => pidResolve(null), 8000)
|
|
501
|
+
let exitResolve
|
|
502
|
+
const exit = new Promise((res) => { exitResolve = res })
|
|
503
|
+
const finish = (value) => {
|
|
504
|
+
if (settled) return
|
|
505
|
+
settled = true
|
|
506
|
+
clearTimeout(pidTimer)
|
|
507
|
+
try { conn.end() } catch {}
|
|
508
|
+
exitResolve(value)
|
|
509
|
+
}
|
|
510
|
+
const push = (target) => (chunk) => {
|
|
511
|
+
const piece = String(chunk)
|
|
512
|
+
if (target.buf.length + piece.length > target.max) { target.lossy = true; return }
|
|
513
|
+
target.buf += piece
|
|
514
|
+
}
|
|
515
|
+
const reader = (target) => () => {
|
|
516
|
+
const delta = target.buf.slice(target.pos)
|
|
517
|
+
target.pos = target.buf.length
|
|
518
|
+
return { delta, lossy: target.lossy }
|
|
519
|
+
}
|
|
520
|
+
// Normalize Windows CRLF output to LF at the chunk boundary so a \r\n
|
|
521
|
+
// split across data events still folds (isolated CRs are preserved).
|
|
522
|
+
const outLf = windows ? createCrlfToLf() : null
|
|
523
|
+
const errLf = windows ? createCrlfToLf() : null
|
|
524
|
+
// The PID marker is the FIRST stdout line, but the first data chunk may
|
|
525
|
+
// hold only part of it, so buffer head bytes until the line completes.
|
|
526
|
+
let head = ''
|
|
527
|
+
let markerResolved = false
|
|
528
|
+
const onOut = (chunk) => {
|
|
529
|
+
const text = outLf === null ? String(chunk) : outLf.feed(chunk)
|
|
530
|
+
if (text === '') return
|
|
531
|
+
if (markerResolved) { push(out)(text); return }
|
|
532
|
+
head += text
|
|
533
|
+
if (windows) {
|
|
534
|
+
const m = /^DWSH_PID=(\d+)\r?\n/.exec(head)
|
|
535
|
+
if (m !== null) {
|
|
536
|
+
markerResolved = true
|
|
537
|
+
clearTimeout(pidTimer)
|
|
538
|
+
pidResolve(Number(m[1]))
|
|
539
|
+
const rest = head.slice(m[0].length)
|
|
540
|
+
head = ''
|
|
541
|
+
if (rest.length > 0) push(out)(rest)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
if (head.length > 4096) {
|
|
545
|
+
// Something else produced output first; give up on the marker.
|
|
546
|
+
markerResolved = true
|
|
547
|
+
clearTimeout(pidTimer)
|
|
548
|
+
pidResolve(null)
|
|
549
|
+
push(out)(head)
|
|
550
|
+
head = ''
|
|
551
|
+
}
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
markerResolved = true
|
|
555
|
+
clearTimeout(pidTimer)
|
|
556
|
+
pidResolve(null)
|
|
557
|
+
push(out)(head)
|
|
558
|
+
head = ''
|
|
559
|
+
}
|
|
560
|
+
const onErr = (chunk) => {
|
|
561
|
+
const text = errLf === null ? String(chunk) : errLf.feed(chunk)
|
|
562
|
+
if (text !== '') push(err)(text)
|
|
563
|
+
}
|
|
564
|
+
conn.on('ready', () => {
|
|
565
|
+
conn.exec(script, (execError, s) => {
|
|
566
|
+
if (execError) { finish({ error: execError.message }); return }
|
|
567
|
+
stream = s
|
|
568
|
+
s.on('data', onOut)
|
|
569
|
+
s.stderr.on('data', onErr)
|
|
570
|
+
s.on('close', (code) => {
|
|
571
|
+
if (outLf !== null) { const rest = outLf.flush(); if (rest !== '') push(out)(rest) }
|
|
572
|
+
if (errLf !== null) { const rest = errLf.flush(); if (rest !== '') push(err)(rest) }
|
|
573
|
+
finish({ exitCode: code })
|
|
574
|
+
})
|
|
575
|
+
s.end()
|
|
576
|
+
})
|
|
577
|
+
})
|
|
578
|
+
conn.on('error', (connError) => finish({ error: connError.message }))
|
|
579
|
+
try { conn.connect(this.connectConfig()) } catch (connectError) { finish({ error: connectError.message }) }
|
|
580
|
+
resolve({
|
|
581
|
+
readOut: reader(out),
|
|
582
|
+
readErr: reader(err),
|
|
583
|
+
exit,
|
|
584
|
+
terminate() {
|
|
585
|
+
const doClose = () => {
|
|
586
|
+
try { if (stream) stream.close() } catch {}
|
|
587
|
+
try { conn.end() } catch {}
|
|
588
|
+
// Settle explicitly: the remote 'close' event is not guaranteed.
|
|
589
|
+
finish({ exitCode: null })
|
|
590
|
+
}
|
|
591
|
+
pid.then((remotePid) => {
|
|
592
|
+
if (typeof remotePid === 'number' && remotePid > 0) {
|
|
593
|
+
void ssh.execShell(`taskkill /PID ${remotePid} /T /F`, {
|
|
594
|
+
timeoutMs: 15000, stdoutMaxBytes: 4096, stderrMaxBytes: 4096,
|
|
595
|
+
}).catch(() => {}).finally(doClose)
|
|
596
|
+
} else {
|
|
597
|
+
doClose()
|
|
598
|
+
}
|
|
599
|
+
})
|
|
600
|
+
},
|
|
601
|
+
})
|
|
602
|
+
})
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Cached remote execution profile ({ family, os, shell }); probed once per
|
|
607
|
+
* target and reused for the process lifetime.
|
|
608
|
+
*/
|
|
609
|
+
async profile() {
|
|
610
|
+
const key = profileCacheKey(this)
|
|
611
|
+
let profile = REMOTE_PROFILE_CACHE.get(key)
|
|
612
|
+
if (profile === undefined) {
|
|
613
|
+
profile = await probeRemoteProfile(this)
|
|
614
|
+
REMOTE_PROFILE_CACHE.set(key, profile)
|
|
615
|
+
}
|
|
616
|
+
return profile
|
|
617
|
+
}
|
|
618
|
+
|
|
296
619
|
async remoteOs() {
|
|
297
620
|
if (this._os === undefined) {
|
|
298
|
-
const
|
|
299
|
-
|
|
621
|
+
const profile = await this.profile()
|
|
622
|
+
if (profile.family === 'posix') this._os = profile.os === 'darwin' ? 'darwin' : 'linux'
|
|
623
|
+
else if (profile.family === 'windows') this._os = 'windows'
|
|
624
|
+
else {
|
|
625
|
+
const res = await this.run('uname -s')
|
|
626
|
+
this._os = (res.stdout ?? '').trim().toLowerCase().startsWith('darwin') ? 'darwin' : 'linux'
|
|
627
|
+
}
|
|
300
628
|
}
|
|
301
629
|
return this._os
|
|
302
630
|
}
|
|
@@ -383,12 +711,25 @@ export class SshClient {
|
|
|
383
711
|
}
|
|
384
712
|
|
|
385
713
|
/**
|
|
386
|
-
* Remote file content hash for post-write verification.
|
|
387
|
-
* `sha256sum` then BSD `shasum -a 256`;
|
|
714
|
+
* Remote file content hash for post-write verification. On POSIX targets
|
|
715
|
+
* tries GNU `sha256sum` then BSD `shasum -a 256`; on Windows targets uses
|
|
716
|
+
* PowerShell `Get-FileHash`. Returns the lowercase hex digest or
|
|
388
717
|
* `undefined` when no such tool exists (verification is then skipped).
|
|
389
718
|
* Locale-independent: the digest is hex, never localized text.
|
|
390
719
|
*/
|
|
391
720
|
async sha256(path) {
|
|
721
|
+
const profile = await this.profile()
|
|
722
|
+
if (profile.family === 'windows') {
|
|
723
|
+
const res = await this.execShell(
|
|
724
|
+
`(Get-FileHash -LiteralPath ${psQuote(toWinPath(path))} -Algorithm SHA256).Hash.ToLower()`,
|
|
725
|
+
{ timeoutMs: 30000 },
|
|
726
|
+
)
|
|
727
|
+
if (res.exitCode === 0) {
|
|
728
|
+
const hash = (res.stdout?.text ?? '').trim().toLowerCase()
|
|
729
|
+
if (/^[0-9a-f]{64}$/.test(hash)) return hash
|
|
730
|
+
}
|
|
731
|
+
return undefined
|
|
732
|
+
}
|
|
392
733
|
for (const cmd of [`sha256sum ${shellQuote(path)}`, `shasum -a 256 ${shellQuote(path)}`]) {
|
|
393
734
|
const res = await this.run(`${cmd} 2>/dev/null`)
|
|
394
735
|
if (res.ok) {
|