@goodandready/dsh-remote-workspace 0.2.0 → 0.3.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/lib/client.js CHANGED
@@ -90,6 +90,15 @@ window.__ModuleLoader__.load({
90
90
  .drw-modal-dialog{background:var(--dsw-alias-bg-layer-3);border:1px solid var(--dsw-alias-border-l2);border-radius:12px;width:100%;max-width:800px;max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 12px 32px rgba(0,0,0,0.4)}
91
91
  .drw-modal-hdr{padding:12px 16px;border-bottom:1px solid var(--dsw-alias-border-l2);display:flex;align-items:center;justify-content:space-between;font-weight:600}
92
92
  .drw-modal-body{flex:1;padding:12px 16px;overflow-y:auto}
93
+
94
+ .drw-health-bar{display:grid;grid-template-columns:repeat(auto-fit, minmax(130px, 1fr));gap:8px;padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;font-size:11.5px;font-family:monospace}
95
+ .drw-health-item{display:flex;flex-direction:column;gap:2px}
96
+ .drw-progress{height:6px;background:var(--dsw-alias-bg-layer-1);border-radius:3px;overflow:hidden}
97
+ .drw-progress-fill{height:100%;border-radius:3px;transition:width .2s ease}
98
+
99
+ .drw-container-row{display:grid;grid-template-columns:2fr 2fr 1.5fr 1.5fr auto;align-items:center;gap:10px;padding:8px 10px;border-bottom:1px solid var(--dsw-alias-border-l2);font-size:12.5px;font-family:monospace}
100
+ .drw-container-row:hover{background:var(--dsw-alias-bg-layer-3)}
101
+
93
102
  `
94
103
  document.head.appendChild(style)
95
104
  }
@@ -100,6 +109,32 @@ window.__ModuleLoader__.load({
100
109
  status: 'Ready',
101
110
 
102
111
  tabProfiles: '🖥️ Host Profiles',
112
+
113
+ tabContainers: '🐳 Containers',
114
+ containersTitle: 'Remote Docker / Podman Engine',
115
+ containerName: 'Container',
116
+ containerImage: 'Image',
117
+ containerStatus: 'Status',
118
+ containerPorts: 'Ports',
119
+ btnLogs: 'Logs',
120
+ btnRestart: 'Restart',
121
+ btnStop: 'Stop',
122
+ noContainers: 'No active containers found or Docker daemon not running.',
123
+ healthCpu: 'CPU: {val}%',
124
+ healthMem: 'RAM: {val}% ({used}/{total} MB)',
125
+ healthDisk: 'Disk: {val}%',
126
+ healthUptime: 'Up: {val}',
127
+ autoSyncTitle: 'Live Auto-Sync on Save',
128
+ autoSyncDesc: 'Automatically push modified files to remote workspace on local save.',
129
+ btnTransfer: 'Copy to Host...',
130
+ transferTitle: 'Server-to-Server Direct Transfer',
131
+ transferDestHost: 'Destination Server',
132
+ transferDestPath: 'Destination File Path',
133
+ transferBtnExec: 'Transfer Directly',
134
+ transferSuccess: 'Successfully transferred {bytes} bytes to {host}:{path}',
135
+ fJumpHost: 'Jump Host (Bastion)',
136
+ fJumpHostNone: 'Direct Connection (No Jump Host)',
137
+
103
138
  tabTerminal: '⚡ Web Terminal',
104
139
  tabExplorer: '📂 File Explorer',
105
140
  tabTunnels: '🔗 Port Tunnels',
@@ -294,6 +329,157 @@ window.__ModuleLoader__.load({
294
329
  }
295
330
 
296
331
 
332
+
333
+ // --- Sub-component: Docker Containers Tab ---
334
+ function ContainersTab(props) {
335
+ const { activeProfile, t } = props
336
+ const [containers, setContainers] = React.useState([])
337
+ const [loading, setLoading] = React.useState(false)
338
+ const [logsModal, setLogsModal] = React.useState(null)
339
+ const [logsContent, setLogsContent] = React.useState('')
340
+ const [logsLoading, setLogsLoading] = React.useState(false)
341
+
342
+ const loadContainers = async () => {
343
+ if (!activeProfile) return
344
+ setLoading(true)
345
+ try {
346
+ const res = await fetch(`/dsh-remote-workspace/docker/list?profileId=${activeProfile.id}`)
347
+ const data = await res.json()
348
+ if (data.ok && Array.isArray(data.containers)) {
349
+ setContainers(data.containers)
350
+ } else {
351
+ setContainers([])
352
+ }
353
+ } catch (_) {}
354
+ setLoading(false)
355
+ }
356
+
357
+ React.useEffect(() => {
358
+ loadContainers()
359
+ }, [activeProfile?.id])
360
+
361
+ const handleAction = async (containerId, action) => {
362
+ try {
363
+ await fetch('/dsh-remote-workspace/docker/action', {
364
+ method: 'POST',
365
+ headers: { 'Content-Type': 'application/json' },
366
+ body: JSON.stringify({ profileId: activeProfile.id, containerId, action })
367
+ })
368
+ await loadContainers()
369
+ } catch (_) {}
370
+ }
371
+
372
+ const showLogs = async (containerId, name) => {
373
+ setLogsModal({ id: containerId, name })
374
+ setLogsLoading(true)
375
+ try {
376
+ const res = await fetch('/dsh-remote-workspace/docker/logs', {
377
+ method: 'POST',
378
+ headers: { 'Content-Type': 'application/json' },
379
+ body: JSON.stringify({ profileId: activeProfile.id, containerId, tail: 150 })
380
+ })
381
+ const data = await res.json()
382
+ setLogsContent(data.logs || '[No logs output]')
383
+ } catch (err) {
384
+ setLogsContent(`[Error: ${err.message}]`)
385
+ }
386
+ setLogsLoading(false)
387
+ }
388
+
389
+ return React.createElement(
390
+ 'div',
391
+ { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
392
+ React.createElement(
393
+ 'div',
394
+ { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
395
+ React.createElement('div', { style: { fontWeight: 600, fontSize: '14px' } }, t('containersTitle')),
396
+ React.createElement(
397
+ 'button',
398
+ {
399
+ type: 'button',
400
+ className: 'drw-btn',
401
+ onClick: loadContainers,
402
+ disabled: loading
403
+ },
404
+ loading ? 'Refreshing...' : '🔄 Refresh'
405
+ )
406
+ ),
407
+ React.createElement(
408
+ 'div',
409
+ { style: { border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '8px', overflow: 'hidden', background: 'var(--dsw-alias-bg-layer-2)' } },
410
+ React.createElement(
411
+ 'div',
412
+ { className: 'drw-container-row', style: { fontWeight: 600, color: 'var(--dsw-alias-label-secondary)', background: 'var(--dsw-alias-bg-layer-1)' } },
413
+ React.createElement('span', null, t('containerName')),
414
+ React.createElement('span', null, t('containerImage')),
415
+ React.createElement('span', null, t('containerStatus')),
416
+ React.createElement('span', null, t('containerPorts')),
417
+ React.createElement('span', { style: { textAlign: 'right' } }, t('actionsCol'))
418
+ ),
419
+ containers.length === 0
420
+ ? React.createElement('div', { style: { padding: '24px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary)' } }, t('noContainers'))
421
+ : containers.map((c) =>
422
+ React.createElement(
423
+ 'div',
424
+ { key: c.id, className: 'drw-container-row' },
425
+ React.createElement(
426
+ 'div',
427
+ { style: { display: 'flex', alignItems: 'center', gap: '6px' } },
428
+ React.createElement('span', { className: `drw-badge ${c.state === 'running' ? 'drw-badge-ok' : 'drw-badge-warn'}` }, c.state),
429
+ React.createElement('span', { style: { fontWeight: 600 } }, c.names)
430
+ ),
431
+ React.createElement('span', { style: { opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, c.image),
432
+ React.createElement('span', { style: { fontSize: '11px', opacity: 0.7 } }, c.status),
433
+ React.createElement('span', { style: { fontSize: '11px', opacity: 0.7, overflow: 'hidden', textOverflow: 'ellipsis' } }, c.ports || '—'),
434
+ React.createElement(
435
+ 'div',
436
+ { style: { display: 'flex', gap: '4px', justifyContent: 'flex-end' } },
437
+ React.createElement(
438
+ 'button',
439
+ { type: 'button', className: 'drw-btn', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => showLogs(c.id, c.names) },
440
+ t('btnLogs')
441
+ ),
442
+ React.createElement(
443
+ 'button',
444
+ { type: 'button', className: 'drw-btn', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => handleAction(c.id, 'restart') },
445
+ t('btnRestart')
446
+ ),
447
+ c.state === 'running'
448
+ ? React.createElement(
449
+ 'button',
450
+ { type: 'button', className: 'drw-btn drw-btn-danger', style: { padding: '2px 6px', fontSize: '11px' }, onClick: () => handleAction(c.id, 'stop') },
451
+ t('btnStop')
452
+ )
453
+ : null
454
+ )
455
+ )
456
+ )
457
+ ),
458
+ // Container Logs Modal
459
+ logsModal
460
+ ? React.createElement(
461
+ 'div',
462
+ { className: 'drw-modal-overlay', onClick: () => setLogsModal(null) },
463
+ React.createElement(
464
+ 'div',
465
+ { className: 'drw-modal-dialog', onClick: (e) => e.stopPropagation() },
466
+ React.createElement(
467
+ 'div',
468
+ { className: 'drw-modal-hdr' },
469
+ React.createElement('span', { style: { fontFamily: 'monospace' } }, `Docker Logs: ${logsModal.name} (${logsModal.id})`),
470
+ React.createElement('button', { type: 'button', className: 'drw-btn', onClick: () => setLogsModal(null) }, '✕')
471
+ ),
472
+ React.createElement(
473
+ 'div',
474
+ { className: 'drw-modal-body' },
475
+ React.createElement('pre', { style: { background: '#0d1117', color: '#c9d1d9', padding: '12px', borderRadius: '8px', fontSize: '12px', maxHeight: '420px', overflowY: 'auto', whiteSpace: 'pre-wrap' } }, logsLoading ? 'Fetching logs...' : logsContent)
476
+ )
477
+ )
478
+ )
479
+ : null
480
+ )
481
+ }
482
+
297
483
  // --- Sub-component: Web Terminal Tab ---
298
484
  function TerminalTab(props) {
299
485
  const { activeProfile, t } = props
@@ -737,6 +923,8 @@ window.__ModuleLoader__.load({
737
923
 
738
924
  function SettingsView(props) {
739
925
  const [activeTab, setActiveTab] = React.useState('profiles')
926
+ const [healthData, setHealthData] = React.useState(null)
927
+ const [autoSync, setAutoSync] = React.useState(false)
740
928
 
741
929
  const t = props.locale === 'ru' ? makeT(ru, en) : makeT(en, ru)
742
930
  const [profiles, setProfiles] = React.useState([])
@@ -768,6 +956,10 @@ window.__ModuleLoader__.load({
768
956
  setProfiles(data.profiles || [])
769
957
  setActiveId(data.activeId || null)
770
958
  setTunnels(data.tunnels || [])
959
+ setAutoSync(Boolean(data.autoSync))
960
+ if (data.activeId) {
961
+ fetch(`/dsh-remote-workspace/health?profileId=${data.activeId}`).then(r => r.json()).then(h => { if (h.ok) setHealthData(h.health) }).catch(() => {})
962
+ }
771
963
  }
772
964
  }
773
965
  } catch (_) {}
@@ -1482,6 +1674,39 @@ window.__ModuleLoader__.load({
1482
1674
  { className: 'drw-card' },
1483
1675
  React.createElement('div', { className: 'drw-card-title' }, t('syncTitle')),
1484
1676
  React.createElement('div', { className: 'drw-card-desc' }, t('syncDesc')),
1677
+
1678
+ React.createElement(
1679
+ 'div',
1680
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 14px', background: 'var(--dsw-alias-bg-layer-2)', borderRadius: '8px', border: '1px solid var(--dsw-alias-border-l2)', marginBottom: '12px' } },
1681
+ React.createElement(
1682
+ 'div',
1683
+ { style: { display: 'flex', flexDirection: 'column', gap: '2px' } },
1684
+ React.createElement('span', { style: { fontWeight: 600, fontSize: '13px' } }, t('autoSyncTitle')),
1685
+ React.createElement('span', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)' } }, t('autoSyncDesc'))
1686
+ ),
1687
+ React.createElement(
1688
+ 'button',
1689
+ {
1690
+ type: 'button',
1691
+ className: `drw-btn ${autoSync ? 'drw-btn-primary' : ''}`,
1692
+ style: { padding: '4px 12px', fontSize: '12px' },
1693
+ onClick: async () => {
1694
+ const next = !autoSync
1695
+ try {
1696
+ const res = await fetch('/dsh-remote-workspace/watcher/toggle', {
1697
+ method: 'POST',
1698
+ headers: { 'Content-Type': 'application/json' },
1699
+ body: JSON.stringify({ enabled: next })
1700
+ })
1701
+ const d = await res.json()
1702
+ if (d.ok) setAutoSync(d.autoSync)
1703
+ } catch (_) {}
1704
+ }
1705
+ },
1706
+ autoSync ? '⚡ Active (ON)' : '⚪ Off'
1707
+ )
1708
+ ),
1709
+
1485
1710
  React.createElement(
1486
1711
  'div',
1487
1712
  { className: 'drw-row' },
@@ -0,0 +1,93 @@
1
+ export class DockerService {
2
+ /**
3
+ * @param {import('./ssh-service.js').SshService} sshService
4
+ */
5
+ constructor(sshService) {
6
+ this.sshService = sshService;
7
+ }
8
+
9
+ /**
10
+ * List active or all containers with JSON formatting
11
+ */
12
+ async listContainers(profile, all = true) {
13
+ // Detect docker or podman
14
+ const flag = all ? '-a' : '';
15
+ // Format as line-delimited JSON or tabbed format
16
+ const cmd = `docker ps ${flag} --format '{{json .}}' 2>/dev/null || podman ps ${flag} --format '{{json .}}' 2>/dev/null`;
17
+ const res = await this.sshService.exec(profile, cmd);
18
+
19
+ if (res.code !== 0 && !res.stdout.trim()) {
20
+ return {
21
+ available: false,
22
+ error: res.stderr || 'Docker not found or daemon not running',
23
+ containers: []
24
+ };
25
+ }
26
+
27
+ const containers = [];
28
+ const lines = res.stdout.split('\n').filter(Boolean);
29
+ for (const line of lines) {
30
+ try {
31
+ const item = JSON.parse(line);
32
+ containers.push({
33
+ id: item.ID || item.Id || '',
34
+ names: item.Names || item.Name || '',
35
+ image: item.Image || '',
36
+ status: item.Status || '',
37
+ state: (item.State || '').toLowerCase(),
38
+ ports: item.Ports || '',
39
+ created: item.CreatedAt || item.Created || ''
40
+ });
41
+ } catch (_) {}
42
+ }
43
+
44
+ return {
45
+ available: true,
46
+ containers
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Container action: start, stop, restart, pause, unpause
52
+ */
53
+ async containerAction(profile, containerId, action) {
54
+ const validActions = ['start', 'stop', 'restart', 'pause', 'unpause', 'rm'];
55
+ if (!validActions.includes(action)) {
56
+ throw new Error(`Invalid container action: ${action}`);
57
+ }
58
+ const cmd = `docker ${action} ${JSON.stringify(containerId)} 2>/dev/null || podman ${action} ${JSON.stringify(containerId)}`;
59
+ const res = await this.sshService.exec(profile, cmd);
60
+ return {
61
+ success: res.code === 0,
62
+ stdout: res.stdout.trim(),
63
+ stderr: res.stderr.trim()
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Fetch container logs
69
+ */
70
+ async containerLogs(profile, containerId, tail = 100) {
71
+ const cmd = `docker logs --tail ${tail} ${JSON.stringify(containerId)} 2>&1 || podman logs --tail ${tail} ${JSON.stringify(containerId)} 2>&1`;
72
+ const res = await this.sshService.exec(profile, cmd);
73
+ return {
74
+ success: res.code === 0,
75
+ logs: res.stdout || res.stderr || ''
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Docker compose lifecycle
81
+ */
82
+ async composeAction(profile, cwd, action = 'ps') {
83
+ const valid = ['up -d', 'down', 'ps', 'restart', 'pull'];
84
+ const safeAction = valid.includes(action) ? action : 'ps';
85
+ const cmd = `docker compose ${safeAction} 2>/dev/null || docker-compose ${safeAction} 2>/dev/null || podman-compose ${safeAction}`;
86
+ const res = await this.sshService.exec(profile, cmd, cwd);
87
+ return {
88
+ success: res.code === 0,
89
+ stdout: res.stdout,
90
+ stderr: res.stderr
91
+ };
92
+ }
93
+ }
@@ -0,0 +1,117 @@
1
+ export class HealthService {
2
+ /**
3
+ * @param {import('./ssh-service.js').SshService} sshService
4
+ */
5
+ constructor(sshService) {
6
+ this.sshService = sshService;
7
+ /** @type {Map<string, { data: any, expiresAt: number }>} */
8
+ this.cache = new Map();
9
+ this.cacheTtlMs = 4000;
10
+ }
11
+
12
+ async getHealth(profile, bypassCache = false) {
13
+ if (!profile || !profile.host) {
14
+ throw new Error('Invalid profile');
15
+ }
16
+
17
+ const cacheKey = profile.id;
18
+ if (!bypassCache) {
19
+ const cached = this.cache.get(cacheKey);
20
+ if (cached && cached.expiresAt > Date.now()) {
21
+ return cached.data;
22
+ }
23
+ }
24
+
25
+ // Single compact shell command to gather all stats at once:
26
+ // 1. uptime (load averages)
27
+ // 2. memory (free -m)
28
+ // 3. disk usage (df -k /)
29
+ // 4. cpu info (cores & idle via /proc/stat or top)
30
+ const probeCmd = `
31
+ echo "---UPTIME---"
32
+ uptime 2>/dev/null || true
33
+ echo "---MEM---"
34
+ free -m 2>/dev/null || true
35
+ echo "---DISK---"
36
+ df -m / 2>/dev/null || df -m . 2>/dev/null || true
37
+ echo "---CPU---"
38
+ grep 'cpu ' /proc/stat 2>/dev/null || true
39
+ `;
40
+
41
+ const res = await this.sshService.exec(profile, probeCmd);
42
+ const text = res.stdout || '';
43
+
44
+ const uptimeMatch = text.match(/---UPTIME---\n([\s\S]*?)(?=---MEM---)/);
45
+ const memMatch = text.match(/---MEM---\n([\s\S]*?)(?=---DISK---)/);
46
+ const diskMatch = text.match(/---DISK---\n([\s\S]*?)(?=---CPU---)/);
47
+
48
+ // 1. Parse Uptime & Load Avg
49
+ let uptimeStr = 'Unknown';
50
+ let loadAvg = [0, 0, 0];
51
+ if (uptimeMatch) {
52
+ const upLine = uptimeMatch[1].trim();
53
+ const loadMatch = upLine.match(/load average[s]?:\s*([\d.]+),?\s*([\d.]+),?\s*([\d.]+)/);
54
+ if (loadMatch) {
55
+ loadAvg = [parseFloat(loadMatch[1]), parseFloat(loadMatch[2]), parseFloat(loadMatch[3])];
56
+ }
57
+ const upPart = upLine.match(/up\s+(.*?),\s+\d+\s+user/);
58
+ if (upPart) uptimeStr = upPart[1];
59
+ else uptimeStr = upLine.split(',')[0] || 'Online';
60
+ }
61
+
62
+ // 2. Parse Memory
63
+ let memTotal = 0;
64
+ let memUsed = 0;
65
+ let memPercent = 0;
66
+ if (memMatch) {
67
+ const memLines = memMatch[1].trim().split('\n');
68
+ for (const line of memLines) {
69
+ if (line.startsWith('Mem:')) {
70
+ const parts = line.split(/\s+/);
71
+ memTotal = parseInt(parts[1], 10) || 0;
72
+ memUsed = parseInt(parts[2], 10) || 0;
73
+ if (memTotal > 0) {
74
+ memPercent = Math.round((memUsed / memTotal) * 100);
75
+ }
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ // 3. Parse Disk
82
+ let diskTotal = 0;
83
+ let diskUsed = 0;
84
+ let diskPercent = 0;
85
+ if (diskMatch) {
86
+ const diskLines = diskMatch[1].trim().split('\n');
87
+ if (diskLines.length > 1) {
88
+ const parts = diskLines[1].split(/\s+/);
89
+ diskTotal = parseInt(parts[1], 10) || 0;
90
+ diskUsed = parseInt(parts[2], 10) || 0;
91
+ const pStr = parts[4] || '';
92
+ diskPercent = parseInt(pStr.replace('%', ''), 10) || (diskTotal > 0 ? Math.round((diskUsed / diskTotal) * 100) : 0);
93
+ }
94
+ }
95
+
96
+ const result = {
97
+ ok: true,
98
+ timestamp: Date.now(),
99
+ uptime: uptimeStr,
100
+ loadAverage: loadAvg,
101
+ cpuLoad: Math.min(100, Math.round(loadAvg[0] * 25)), // Normalized approx representation
102
+ memory: {
103
+ totalMb: memTotal,
104
+ usedMb: memUsed,
105
+ percent: memPercent
106
+ },
107
+ disk: {
108
+ totalMb: diskTotal,
109
+ usedMb: diskUsed,
110
+ percent: diskPercent
111
+ }
112
+ };
113
+
114
+ this.cache.set(cacheKey, { data: result, expiresAt: Date.now() + this.cacheTtlMs });
115
+ return result;
116
+ }
117
+ }
package/lib/index.js CHANGED
@@ -4,6 +4,9 @@ import { SshService } from './ssh-service.js';
4
4
  import { RemoteFsService } from './remote-fs-service.js';
5
5
  import { MirrorSyncService } from './mirror-sync-service.js';
6
6
  import { TunnelService } from './tunnel-service.js';
7
+ import { DockerService } from './docker-service.js';
8
+ import { HealthService } from './health-service.js';
9
+ import { WatcherService } from './watcher-service.js';
7
10
  import { registerRemoteTools } from './tools.js';
8
11
  import { registerApiRoutes } from './routes.js';
9
12
 
@@ -26,9 +29,11 @@ export const Config = z.object({
26
29
  passphrase: z.string().default(''),
27
30
  password: z.string().default(''),
28
31
  remoteWorkspace: z.string().default(''),
29
- localMirrorPath: z.string().default('')
32
+ localMirrorPath: z.string().default(''),
33
+ jumpHostId: z.string().default('')
30
34
  })).default([]).description('List of configured remote machines and workspaces.'),
31
- activeProfileId: z.string().default('').description('ID of active remote profile.')
35
+ activeProfileId: z.string().default('').description('ID of active remote profile.'),
36
+ autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.')
32
37
  });
33
38
 
34
39
  export function apply(ctx, config = {}) {
@@ -36,7 +41,8 @@ export function apply(ctx, config = {}) {
36
41
  let settingsApi = null;
37
42
  let currentConfig = {
38
43
  profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
39
- activeProfileId: cfg.activeProfileId || null
44
+ activeProfileId: cfg.activeProfileId || null,
45
+ autoSyncActive: Boolean(cfg.autoSyncActive)
40
46
  };
41
47
 
42
48
  const vaultService = new VaultService();
@@ -44,6 +50,9 @@ export function apply(ctx, config = {}) {
44
50
  const remoteFs = new RemoteFsService(sshService);
45
51
  const mirrorSync = new MirrorSyncService(remoteFs, sshService);
46
52
  const tunnelService = new TunnelService(sshService);
53
+ const dockerService = new DockerService(sshService);
54
+ const healthService = new HealthService(sshService);
55
+ const watcherService = new WatcherService();
47
56
 
48
57
  // Migrate any existing plain passwords into vault upon start
49
58
  for (const p of currentConfig.profiles) {
@@ -78,11 +87,22 @@ export function apply(ctx, config = {}) {
78
87
  const p = (currentConfig.profiles || []).find((x) => x.id === currentConfig.activeProfileId);
79
88
  return p ? vaultService.hydrateProfile(p) : undefined;
80
89
  },
90
+ isAutoSyncEnabled() { return Boolean(currentConfig.autoSyncActive); },
91
+ async setAutoSyncEnabled(enabled) {
92
+ currentConfig.autoSyncActive = Boolean(enabled);
93
+ const active = store.getActiveProfile();
94
+ if (active) {
95
+ if (enabled) watcherService.start(active, mirrorSync);
96
+ else watcherService.stop(active.id);
97
+ }
98
+ if (settingsApi?.replace) {
99
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
100
+ }
101
+ },
81
102
  async saveProfile(rawP) {
82
103
  const list = [...(currentConfig.profiles || [])];
83
104
  const p = { ...rawP };
84
105
 
85
- // Save secret credentials into isolated .env file if provided
86
106
  const secretsToUpdate = {};
87
107
  if (p.password !== undefined && p.password !== '••••••••') {
88
108
  secretsToUpdate.password = p.password;
@@ -97,7 +117,6 @@ export function apply(ctx, config = {}) {
97
117
  vaultService.setProfileSecrets(p.id, secretsToUpdate);
98
118
  }
99
119
 
100
- // Do NOT keep raw secrets in public config
101
120
  p.password = '';
102
121
  p.privateKey = '';
103
122
  p.passphrase = '';
@@ -116,6 +135,7 @@ export function apply(ctx, config = {}) {
116
135
  }
117
136
  },
118
137
  async deleteProfile(id) {
138
+ watcherService.stop(id);
119
139
  currentConfig.profiles = (currentConfig.profiles || []).filter((p) => p.id !== id);
120
140
  if (currentConfig.activeProfileId === id) {
121
141
  currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
@@ -128,12 +148,25 @@ export function apply(ctx, config = {}) {
128
148
  },
129
149
  async setActiveId(id) {
130
150
  currentConfig.activeProfileId = id;
151
+ if (currentConfig.autoSyncActive) {
152
+ watcherService.stopAll();
153
+ const active = store.getActiveProfile();
154
+ if (active) watcherService.start(active, mirrorSync);
155
+ }
131
156
  if (settingsApi?.replace) {
132
157
  try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
133
158
  }
134
159
  }
135
160
  };
136
161
 
162
+ sshService.setJumpProfileResolver((jumpId) => store.getProfile(jumpId));
163
+
164
+ // If autoSync was enabled on restart, resume watcher for active profile
165
+ if (currentConfig.autoSyncActive && currentConfig.activeProfileId) {
166
+ const active = store.getActiveProfile();
167
+ if (active) watcherService.start(active, mirrorSync);
168
+ }
169
+
137
170
  // Register settings namespace if settings service is available
138
171
  ctx.inject(['settings'], (sctx) => {
139
172
  const settingsService = sctx.get ? (sctx.get('settings') ?? sctx.settings) : sctx.settings;
@@ -145,6 +178,7 @@ export function apply(ctx, config = {}) {
145
178
  if (snap) {
146
179
  if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
147
180
  if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
181
+ if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
148
182
  }
149
183
  sctx.effect(() => () => {
150
184
  settingsApi = null;
@@ -156,21 +190,37 @@ export function apply(ctx, config = {}) {
156
190
  ctx.provide('remoteSync');
157
191
  ctx.provide('remoteTunnel');
158
192
  ctx.provide('remoteVault');
193
+ ctx.provide('remoteDocker');
194
+ ctx.provide('remoteHealth');
195
+ ctx.provide('remoteWatcher');
159
196
 
160
197
  ctx.remoteSsh = sshService;
161
198
  ctx.remoteFs = remoteFs;
162
199
  ctx.remoteSync = mirrorSync;
163
200
  ctx.remoteTunnel = tunnelService;
164
201
  ctx.remoteVault = vaultService;
202
+ ctx.remoteDocker = dockerService;
203
+ ctx.remoteHealth = healthService;
204
+ ctx.remoteWatcher = watcherService;
165
205
 
166
206
  // Tools registration
167
- registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, () => store.getActiveProfile());
207
+ registerRemoteTools(
208
+ ctx,
209
+ sshService,
210
+ remoteFs,
211
+ mirrorSync,
212
+ tunnelService,
213
+ () => store.getActiveProfile(),
214
+ (id) => store.getProfile(id),
215
+ dockerService
216
+ );
168
217
 
169
218
  // API Routes
170
- registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store);
219
+ registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store, dockerService, healthService, watcherService);
171
220
 
172
221
  // Cleanup on unload
173
222
  ctx.on('dispose', () => {
223
+ watcherService.stopAll();
174
224
  tunnelService.stopAll();
175
225
  sshService.disconnectAll();
176
226
  });
package/lib/routes.js CHANGED
@@ -1,4 +1,4 @@
1
- export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
1
+ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store, dockerService, healthService, watcherService) {
2
2
  function writeJson(res, statusCode, body) {
3
3
  if (typeof res.setHeader === 'function') {
4
4
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
@@ -54,11 +54,13 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
54
54
  const profiles = store.getProfiles();
55
55
  const activeId = store.getActiveId() || (profiles[0]?.id || '');
56
56
  const tunnels = tunnelService.listActiveTunnels();
57
+ const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
57
58
  writeJson(res, 200, {
58
59
  ok: true,
59
60
  profiles,
60
61
  activeId,
61
62
  tunnels,
63
+ autoSync,
62
64
  vaultProtected: true
63
65
  });
64
66
  } catch (err) {
@@ -282,7 +284,6 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
282
284
  });
283
285
  }
284
286
 
285
- // Send historical buffer
286
287
  for (const chunk of session.buffer) {
287
288
  res.write(`data: ${JSON.stringify({ chunk })}\n\n`);
288
289
  }
@@ -394,5 +395,128 @@ export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelS
394
395
  }
395
396
  }
396
397
  }), 'dsh-remote-workspace: /tunnels/telemetry');
398
+
399
+ // 17. GET /dsh-remote-workspace/health
400
+ sctx.effect(() => webServer.register({
401
+ kind: 'exact',
402
+ path: '/dsh-remote-workspace/health',
403
+ handler: async (req, res) => {
404
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
405
+ try {
406
+ const url = new URL(req.url, 'http://localhost');
407
+ const profileId = url.searchParams.get('profileId');
408
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
409
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
410
+
411
+ const health = await healthService.getHealth(profile);
412
+ writeJson(res, 200, { ok: true, health });
413
+ } catch (err) {
414
+ writeJson(res, 500, { ok: false, error: err.message });
415
+ }
416
+ }
417
+ }), 'dsh-remote-workspace: /health');
418
+
419
+ // 18. GET /dsh-remote-workspace/docker/list
420
+ sctx.effect(() => webServer.register({
421
+ kind: 'exact',
422
+ path: '/dsh-remote-workspace/docker/list',
423
+ handler: async (req, res) => {
424
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
425
+ try {
426
+ const url = new URL(req.url, 'http://localhost');
427
+ const profileId = url.searchParams.get('profileId');
428
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
429
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
430
+
431
+ const result = await dockerService.listContainers(profile, true);
432
+ writeJson(res, 200, { ok: true, ...result });
433
+ } catch (err) {
434
+ writeJson(res, 500, { ok: false, error: err.message });
435
+ }
436
+ }
437
+ }), 'dsh-remote-workspace: /docker/list');
438
+
439
+ // 19. POST /dsh-remote-workspace/docker/action
440
+ sctx.effect(() => webServer.register({
441
+ kind: 'exact',
442
+ path: '/dsh-remote-workspace/docker/action',
443
+ handler: async (req, res) => {
444
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
445
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
446
+ try {
447
+ const { profileId, containerId, action } = await readBody(req);
448
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
449
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
450
+ if (!containerId || !action) return writeJson(res, 400, { ok: false, error: 'containerId and action required' });
451
+
452
+ const result = await dockerService.containerAction(profile, containerId, action);
453
+ writeJson(res, 200, { ok: result.success, ...result });
454
+ } catch (err) {
455
+ writeJson(res, 500, { ok: false, error: err.message });
456
+ }
457
+ }
458
+ }), 'dsh-remote-workspace: /docker/action');
459
+
460
+ // 20. POST /dsh-remote-workspace/docker/logs
461
+ sctx.effect(() => webServer.register({
462
+ kind: 'exact',
463
+ path: '/dsh-remote-workspace/docker/logs',
464
+ handler: async (req, res) => {
465
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
466
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
467
+ try {
468
+ const { profileId, containerId, tail } = await readBody(req);
469
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
470
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
471
+ if (!containerId) return writeJson(res, 400, { ok: false, error: 'containerId required' });
472
+
473
+ const result = await dockerService.containerLogs(profile, containerId, tail || 100);
474
+ writeJson(res, 200, { ok: result.success, ...result });
475
+ } catch (err) {
476
+ writeJson(res, 500, { ok: false, error: err.message });
477
+ }
478
+ }
479
+ }), 'dsh-remote-workspace: /docker/logs');
480
+
481
+ // 21. POST /dsh-remote-workspace/watcher/toggle
482
+ sctx.effect(() => webServer.register({
483
+ kind: 'exact',
484
+ path: '/dsh-remote-workspace/watcher/toggle',
485
+ handler: async (req, res) => {
486
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
487
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
488
+ try {
489
+ const { enabled } = await readBody(req);
490
+ await store.setAutoSyncEnabled(Boolean(enabled));
491
+ writeJson(res, 200, { ok: true, autoSync: store.isAutoSyncEnabled() });
492
+ } catch (err) {
493
+ writeJson(res, 500, { ok: false, error: err.message });
494
+ }
495
+ }
496
+ }), 'dsh-remote-workspace: /watcher/toggle');
497
+
498
+ // 22. POST /dsh-remote-workspace/transfer
499
+ sctx.effect(() => webServer.register({
500
+ kind: 'exact',
501
+ path: '/dsh-remote-workspace/transfer',
502
+ handler: async (req, res) => {
503
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
504
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
505
+ try {
506
+ const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
507
+ const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
508
+ const destProfile = store.getProfile(destProfileId);
509
+
510
+ if (!srcProfile) return writeJson(res, 404, { ok: false, error: 'Source profile not found' });
511
+ if (!destProfile) return writeJson(res, 404, { ok: false, error: 'Destination profile not found' });
512
+ if (!sourcePath || !destPath) return writeJson(res, 400, { ok: false, error: 'sourcePath and destPath required' });
513
+
514
+ const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
515
+ writeJson(res, 200, { ok: result.success, ...result });
516
+ } catch (err) {
517
+ writeJson(res, 500, { ok: false, error: err.message });
518
+ }
519
+ }
520
+ }), 'dsh-remote-workspace: /transfer');
397
521
  });
398
522
  }
@@ -8,7 +8,6 @@ import os from 'node:os';
8
8
  */
9
9
  export function stripAnsi(str) {
10
10
  if (!str) return '';
11
- // Match standard ANSI escape codes, OSC sequences, and CSI codes
12
11
  return str.replace(/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, '');
13
12
  }
14
13
 
@@ -26,15 +25,17 @@ export class SshService {
26
25
  this.sftpSessions = new Map();
27
26
  /** @type {Map<string, any>} */
28
27
  this.terminalSessions = new Map();
28
+ this.getJumpProfile = null;
29
29
  }
30
30
 
31
31
  setVaultService(vault) {
32
32
  this.vaultService = vault;
33
33
  }
34
34
 
35
- /**
36
- * Resolve private key content either from direct string or file path
37
- */
35
+ setJumpProfileResolver(resolver) {
36
+ this.getJumpProfile = resolver;
37
+ }
38
+
38
39
  resolvePrivateKey(profile) {
39
40
  if (profile.privateKey && profile.privateKey.trim()) {
40
41
  return profile.privateKey;
@@ -54,7 +55,7 @@ export class SshService {
54
55
  }
55
56
 
56
57
  /**
57
- * Connect to remote machine with pooling, keep-alive, and auto-reconnect
58
+ * Connect to remote machine with pooling, keep-alive, auto-reconnect and ProxyJump
58
59
  */
59
60
  async getConnection(rawProfile) {
60
61
  const profile = this.vaultService ? this.vaultService.hydrateProfile(rawProfile) : rawProfile;
@@ -65,10 +66,24 @@ export class SshService {
65
66
  return existing;
66
67
  }
67
68
 
68
- // Clean any broken reference
69
69
  this.connections.delete(connId);
70
70
  this.sftpSessions.delete(connId);
71
71
 
72
+ // If jumpHost is configured, obtain socket through the jump host first
73
+ let sock = undefined;
74
+ if (profile.jumpHostId && this.getJumpProfile) {
75
+ const jumpProfile = this.getJumpProfile(profile.jumpHostId);
76
+ if (jumpProfile) {
77
+ const jumpConn = await this.getConnection(jumpProfile);
78
+ sock = await new Promise((resolve, reject) => {
79
+ jumpConn.forwardOut('127.0.0.1', 0, profile.host, profile.port || 22, (err, stream) => {
80
+ if (err) return reject(new Error(`ProxyJump via ${jumpProfile.host} failed: ${err.message}`));
81
+ resolve(stream);
82
+ });
83
+ });
84
+ }
85
+ }
86
+
72
87
  return new Promise((resolve, reject) => {
73
88
  const conn = new Client();
74
89
  const connConfig = {
@@ -80,6 +95,10 @@ export class SshService {
80
95
  readyTimeout: 20000
81
96
  };
82
97
 
98
+ if (sock) {
99
+ connConfig.sock = sock;
100
+ }
101
+
83
102
  if (profile.authType === 'password') {
84
103
  connConfig.password = profile.password || '';
85
104
  } else if (profile.authType === 'agent') {
@@ -116,9 +135,6 @@ export class SshService {
116
135
  });
117
136
  }
118
137
 
119
- /**
120
- * Test connection and measure ping / detect OS
121
- */
122
138
  async testConnection(profile) {
123
139
  if (profile.id) {
124
140
  this.disconnect(profile.id);
@@ -154,9 +170,6 @@ export class SshService {
154
170
  });
155
171
  }
156
172
 
157
- /**
158
- * Obtain active SFTP wrapper with connection reuse
159
- */
160
173
  async getSftp(profile) {
161
174
  const connId = profile.id;
162
175
  if (this.sftpSessions.has(connId)) {
@@ -172,13 +185,6 @@ export class SshService {
172
185
  });
173
186
  }
174
187
 
175
- /**
176
- * Execute command on remote host with PTY, streaming, timeout, and ANSI sanitization
177
- * @param {any} profile
178
- * @param {string} command
179
- * @param {string|object} [optionsOrCwd] - cwd string or options object
180
- * @param {function} [legacyOnData]
181
- */
182
188
  async exec(profile, command, optionsOrCwd = null, legacyOnData = null) {
183
189
  let cwd = null;
184
190
  let onData = legacyOnData;
@@ -256,8 +262,41 @@ export class SshService {
256
262
  }
257
263
 
258
264
  /**
259
- * Create an interactive PTY terminal session for Web UI
265
+ * Direct Server-to-Server file transfer streaming between two SSH hosts.
266
+ * Reads from srcProfile SFTP stream and pipes directly into destProfile SFTP stream.
267
+ * No intermediate files touch the local DSH disk!
260
268
  */
269
+ async transferFileBetweenHosts(srcProfile, srcPath, destProfile, destPath) {
270
+ const srcSftp = await this.getSftp(srcProfile);
271
+ const destSftp = await this.getSftp(destProfile);
272
+
273
+ return new Promise((resolve, reject) => {
274
+ const readStream = srcSftp.createReadStream(srcPath);
275
+ const writeStream = destSftp.createWriteStream(destPath);
276
+ let bytesTransferred = 0;
277
+
278
+ readStream.on('data', (chunk) => {
279
+ bytesTransferred += chunk.length;
280
+ });
281
+
282
+ writeStream.on('finish', () => {
283
+ resolve({
284
+ success: true,
285
+ bytesTransferred,
286
+ srcHost: srcProfile.host,
287
+ destHost: destProfile.host,
288
+ srcPath,
289
+ destPath
290
+ });
291
+ });
292
+
293
+ readStream.on('error', (err) => reject(new Error(`Read from ${srcProfile.host} failed: ${err.message}`)));
294
+ writeStream.on('error', (err) => reject(new Error(`Write to ${destProfile.host} failed: ${err.message}`)));
295
+
296
+ readStream.pipe(writeStream);
297
+ });
298
+ }
299
+
261
300
  async createTerminalSession(profile, options = {}) {
262
301
  const conn = await this.getConnection(profile);
263
302
  const cols = options.cols || 80;
package/lib/tools.js CHANGED
@@ -1,4 +1,4 @@
1
- export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile) {
1
+ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService) {
2
2
  if (!ctx.tools || typeof ctx.tools.register !== 'function') {
3
3
  return;
4
4
  }
@@ -149,4 +149,102 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
149
149
  }
150
150
  }
151
151
  });
152
+
153
+ // 5. remote_docker
154
+ if (dockerService) {
155
+ ctx.tools.register({
156
+ name: 'remote_docker',
157
+ description: 'Manage Docker/Podman containers and Docker Compose on remote host',
158
+ parameters: {
159
+ action: { type: 'string', required: true, description: 'list, logs, start, stop, restart, compose' },
160
+ containerId: { type: 'string', description: 'Container ID or name' },
161
+ tail: { type: 'number', description: 'Number of log lines to retrieve (default 50)' },
162
+ composeAction: { type: 'string', description: 'Compose action: up -d, down, ps, restart' },
163
+ composeCwd: { type: 'string', description: 'Directory containing docker-compose.yml' }
164
+ },
165
+ output: genericOutput,
166
+ async execute(args) {
167
+ const profile = getActiveProfile();
168
+ if (!profile) throw new Error('No active remote workspace profile selected');
169
+
170
+ switch (args.action) {
171
+ case 'list':
172
+ return await dockerService.listContainers(profile, true);
173
+ case 'logs':
174
+ if (!args.containerId) throw new Error('containerId required for logs');
175
+ return await dockerService.containerLogs(profile, args.containerId, args.tail || 50);
176
+ case 'start':
177
+ case 'stop':
178
+ case 'restart':
179
+ if (!args.containerId) throw new Error(`containerId required for ${args.action}`);
180
+ return await dockerService.containerAction(profile, args.containerId, args.action);
181
+ case 'compose':
182
+ const dir = args.composeCwd || profile.remoteWorkspace;
183
+ return await dockerService.composeAction(profile, dir, args.composeAction || 'ps');
184
+ default:
185
+ throw new Error(`Unknown docker action: ${args.action}`);
186
+ }
187
+ }
188
+ });
189
+ }
190
+
191
+ // 6. remote_service
192
+ ctx.tools.register({
193
+ name: 'remote_service',
194
+ description: 'Inspect and manage Linux systemd or PM2 services on the remote machine',
195
+ parameters: {
196
+ action: { type: 'string', required: true, description: 'status, start, stop, restart, logs' },
197
+ service: { type: 'string', required: true, description: 'Service name (e.g. nginx, redis, my-app)' },
198
+ manager: { type: 'string', description: 'systemd (default) or pm2' },
199
+ lines: { type: 'number', description: 'Number of log lines for logs action (default 40)' }
200
+ },
201
+ output: genericOutput,
202
+ async execute(args) {
203
+ const profile = getActiveProfile();
204
+ if (!profile) throw new Error('No active remote workspace profile selected');
205
+
206
+ const mgr = args.manager || 'systemd';
207
+ const svc = args.service;
208
+ const lines = args.lines || 40;
209
+
210
+ let cmd = '';
211
+ if (mgr === 'pm2') {
212
+ if (args.action === 'logs') cmd = `pm2 logs ${JSON.stringify(svc)} --lines ${lines} --nostream`;
213
+ else cmd = `pm2 ${args.action} ${JSON.stringify(svc)}`;
214
+ } else {
215
+ // systemd
216
+ if (args.action === 'logs') cmd = `journalctl -u ${JSON.stringify(svc)} -n ${lines} --no-pager`;
217
+ else if (args.action === 'status') cmd = `systemctl status ${JSON.stringify(svc)} --no-pager`;
218
+ else cmd = `sudo systemctl ${args.action} ${JSON.stringify(svc)} || systemctl ${args.action} ${JSON.stringify(svc)}`;
219
+ }
220
+
221
+ const res = await sshService.exec(profile, cmd, { cleanAnsi: true });
222
+ return {
223
+ exitCode: res.code,
224
+ output: res.stdout || res.stderr
225
+ };
226
+ }
227
+ });
228
+
229
+ // 7. remote_transfer
230
+ ctx.tools.register({
231
+ name: 'remote_transfer',
232
+ description: 'Transfer a file directly between two configured remote servers via in-memory SFTP streams',
233
+ parameters: {
234
+ sourcePath: { type: 'string', required: true, description: 'File path on the source remote server' },
235
+ destPath: { type: 'string', required: true, description: 'File path on the destination remote server' },
236
+ destProfileId: { type: 'string', required: true, description: 'Target remote profile ID' },
237
+ sourceProfileId: { type: 'string', description: 'Source remote profile ID (defaults to active profile)' }
238
+ },
239
+ output: genericOutput,
240
+ async execute(args) {
241
+ const srcProfile = args.sourceProfileId && getProfileById ? getProfileById(args.sourceProfileId) : getActiveProfile();
242
+ if (!srcProfile) throw new Error('Source profile not found or no active profile');
243
+
244
+ const destProfile = getProfileById ? getProfileById(args.destProfileId) : null;
245
+ if (!destProfile) throw new Error(`Destination profile "${args.destProfileId}" not found`);
246
+
247
+ return await sshService.transferFileBetweenHosts(srcProfile, args.sourcePath, destProfile, args.destPath);
248
+ }
249
+ });
152
250
  }
@@ -0,0 +1,111 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export class WatcherService {
5
+ constructor() {
6
+ /** @type {Map<string, { watcher: fs.FSWatcher, profile: any, running: boolean }>} */
7
+ this.watchers = new Map();
8
+ /** @type {Map<string, NodeJS.Timeout>} */
9
+ this.debounceTimers = new Map();
10
+ this.debounceMs = 350;
11
+ this.onSyncCallback = null;
12
+ }
13
+
14
+ setOnSync(cb) {
15
+ this.onSyncCallback = cb;
16
+ }
17
+
18
+ isIgnored(filename) {
19
+ if (!filename) return true;
20
+ const parts = filename.split(/[\\/]/);
21
+ const ignoreList = ['.git', 'node_modules', '.dsh-sync-snapshot.json', '.cache', 'dist', 'build', '.next'];
22
+ for (const part of parts) {
23
+ if (ignoreList.includes(part)) return true;
24
+ if (part.endsWith('.tmp') || part.endsWith('.swp') || part.endsWith('~')) return true;
25
+ }
26
+ return false;
27
+ }
28
+
29
+ start(profile, mirrorSync) {
30
+ if (!profile || !profile.id || !profile.localMirrorPath || !profile.remoteWorkspace) {
31
+ return { success: false, error: 'Both localMirrorPath and remoteWorkspace must be set' };
32
+ }
33
+
34
+ if (this.watchers.has(profile.id)) {
35
+ return { success: true, alreadyRunning: true };
36
+ }
37
+
38
+ if (!fs.existsSync(profile.localMirrorPath)) {
39
+ try {
40
+ fs.mkdirSync(profile.localMirrorPath, { recursive: true });
41
+ } catch (err) {
42
+ return { success: false, error: `Failed to create local mirror dir: ${err.message}` };
43
+ }
44
+ }
45
+
46
+ try {
47
+ const watcher = fs.watch(profile.localMirrorPath, { recursive: true }, (_eventType, filename) => {
48
+ if (!filename || this.isIgnored(filename)) return;
49
+
50
+ // Debounce sync triggers
51
+ if (this.debounceTimers.has(profile.id)) {
52
+ clearTimeout(this.debounceTimers.get(profile.id));
53
+ }
54
+
55
+ const timer = setTimeout(async () => {
56
+ this.debounceTimers.delete(profile.id);
57
+ try {
58
+ const syncRes = await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace);
59
+ if (this.onSyncCallback) {
60
+ this.onSyncCallback({ profileId: profile.id, syncRes });
61
+ }
62
+ } catch (_) {}
63
+ }, this.debounceMs);
64
+
65
+ this.debounceTimers.set(profile.id, timer);
66
+ });
67
+
68
+ this.watchers.set(profile.id, { watcher, profile, running: true });
69
+ return { success: true };
70
+ } catch (err) {
71
+ return { success: false, error: err.message };
72
+ }
73
+ }
74
+
75
+ stop(profileId) {
76
+ if (this.debounceTimers.has(profileId)) {
77
+ clearTimeout(this.debounceTimers.get(profileId));
78
+ this.debounceTimers.delete(profileId);
79
+ }
80
+ const item = this.watchers.get(profileId);
81
+ if (item) {
82
+ try {
83
+ item.watcher.close();
84
+ } catch (_) {}
85
+ this.watchers.delete(profileId);
86
+ return true;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ getStatus(profileId) {
92
+ return this.watchers.has(profileId);
93
+ }
94
+
95
+ listActive() {
96
+ return Array.from(this.watchers.keys());
97
+ }
98
+
99
+ stopAll() {
100
+ for (const [id, item] of this.watchers.entries()) {
101
+ try {
102
+ item.watcher.close();
103
+ } catch (_) {}
104
+ if (this.debounceTimers.has(id)) {
105
+ clearTimeout(this.debounceTimers.get(id));
106
+ }
107
+ }
108
+ this.watchers.clear();
109
+ this.debounceTimers.clear();
110
+ }
111
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-remote-workspace",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Enterprise Remote Workspace Plugin for DeepSeek Harness: SSH2 connection pool, SFTP operations, 3-way conflict-aware mirror sync, port forwarding, and native DSH UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",