@goodandready/dsh-remote-workspace 0.2.0 → 0.3.1

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.
@@ -0,0 +1,57 @@
1
+ export class DiagnoseService {
2
+ /**
3
+ * @param {import('./ssh-service.js').SshService} sshService
4
+ */
5
+ constructor(sshService) {
6
+ this.sshService = sshService;
7
+ }
8
+
9
+ /**
10
+ * Diagnose system aspects on remote host
11
+ * @param {any} profile
12
+ * @param {'ports'|'disk'|'memory'|'oom_killer'|'service_logs'} category
13
+ * @param {string} [target] optional port, service name, or path
14
+ */
15
+ async diagnose(profile, category, target = '') {
16
+ if (!profile || !profile.host) throw new Error('Invalid profile');
17
+
18
+ let cmd = '';
19
+ switch (category) {
20
+ case 'ports': {
21
+ const filter = target ? `grep ':${target} '` : 'cat';
22
+ cmd = `(ss -tulpn 2>/dev/null || netstat -tulpn 2>/dev/null || lsof -i -P -n 2>/dev/null) | ${filter} | head -n 40`;
23
+ break;
24
+ }
25
+ case 'disk': {
26
+ const path = target || '/';
27
+ cmd = `df -h ${path} 2>/dev/null; echo "---TOP 10 DIRS---"; du -ahx ${path} 2>/dev/null | sort -rh | head -n 10`;
28
+ break;
29
+ }
30
+ case 'memory': {
31
+ cmd = `free -h 2>/dev/null || free -m; echo "---TOP 10 PROCESSES BY RAM---"; ps aux --sort=-%mem 2>/dev/null | head -n 11`;
32
+ break;
33
+ }
34
+ case 'oom_killer': {
35
+ cmd = `(dmesg -T 2>/dev/null || dmesg 2>/dev/null) | grep -i -E 'oom|out of memory|killed process' | tail -n 25 || echo "No OOM events detected"`;
36
+ break;
37
+ }
38
+ case 'service_logs': {
39
+ if (!target) throw new Error('Target service name required for service_logs');
40
+ const safeName = target.replace(/[^a-zA-Z0-9_.-]/g, '');
41
+ cmd = `(journalctl -u ${safeName} -n 40 --no-pager 2>/dev/null || pm2 logs ${safeName} --lines 40 --nostream 2>/dev/null || tail -n 40 /var/log/${safeName}.log 2>/dev/null || echo "No logs found for ${safeName}")`;
42
+ break;
43
+ }
44
+ default:
45
+ throw new Error(`Unsupported diagnosis category: ${category}`);
46
+ }
47
+
48
+ const res = await this.sshService.exec(profile, cmd);
49
+ const code = res.code !== undefined ? res.code : (res.exitCode !== undefined ? res.exitCode : 0);
50
+ return {
51
+ ok: code === 0,
52
+ category,
53
+ target: target || null,
54
+ output: (res.stdout || res.stderr || '').trim()
55
+ };
56
+ }
57
+ }
@@ -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,122 @@
1
+ export class EnvService {
2
+ /**
3
+ * @param {import('./remote-fs-service.js').RemoteFsService} remoteFsService
4
+ */
5
+ constructor(remoteFsService) {
6
+ this.remoteFsService = remoteFsService;
7
+ }
8
+
9
+ /**
10
+ * Parse .env text into structured entries (preserving comments and order)
11
+ * @param {string} text
12
+ */
13
+ parseEnv(text) {
14
+ if (!text) return [];
15
+ const lines = text.split('\n');
16
+ const result = [];
17
+
18
+ for (let i = 0; i < lines.length; i++) {
19
+ const line = lines[i];
20
+ const trimmed = line.trim();
21
+ if (!trimmed) {
22
+ result.push({ type: 'empty', raw: line });
23
+ continue;
24
+ }
25
+ if (trimmed.startsWith('#')) {
26
+ result.push({ type: 'comment', raw: line });
27
+ continue;
28
+ }
29
+
30
+ const eqIdx = line.indexOf('=');
31
+ if (eqIdx === -1) {
32
+ result.push({ type: 'raw', raw: line });
33
+ continue;
34
+ }
35
+
36
+ const key = line.slice(0, eqIdx).trim();
37
+ let val = line.slice(eqIdx + 1);
38
+ // Check if value is wrapped in quotes
39
+ let isQuoted = false;
40
+ const valTrimmed = val.trim();
41
+ if ((valTrimmed.startsWith('"') && valTrimmed.endsWith('"')) ||
42
+ (valTrimmed.startsWith("'") && valTrimmed.endsWith("'"))) {
43
+ isQuoted = true;
44
+ val = valTrimmed.slice(1, -1);
45
+ }
46
+
47
+ result.push({
48
+ type: 'var',
49
+ key,
50
+ value: val,
51
+ isQuoted,
52
+ raw: line
53
+ });
54
+ }
55
+
56
+ return result;
57
+ }
58
+
59
+ /**
60
+ * Read and parse remote .env file
61
+ */
62
+ async readEnv(profile, filePath) {
63
+ const content = await this.remoteFsService.readFile(profile, filePath);
64
+ const parsed = this.parseEnv(content);
65
+ return {
66
+ filePath,
67
+ entries: parsed,
68
+ raw: content
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Set or update variable in remote .env file atomically
74
+ */
75
+ async setEnvVar(profile, filePath, key, value) {
76
+ if (!key || typeof key !== 'string') throw new Error('Key must be non-empty string');
77
+
78
+ let currentContent = '';
79
+ try {
80
+ currentContent = await this.remoteFsService.readFile(profile, filePath);
81
+ } catch {
82
+ currentContent = '';
83
+ }
84
+
85
+ const lines = currentContent ? currentContent.split('\n') : [];
86
+ let updated = false;
87
+ const newLines = [];
88
+
89
+ for (const line of lines) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed.startsWith('#') && trimmed.includes('=')) {
92
+ const k = trimmed.slice(0, trimmed.indexOf('=')).trim();
93
+ if (k === key) {
94
+ // Quote if contains spaces or special symbols
95
+ const formattedVal = (value.includes(' ') || value.includes('\n')) ? `"${value}"` : value;
96
+ newLines.push(`${key}=${formattedVal}`);
97
+ updated = true;
98
+ continue;
99
+ }
100
+ }
101
+ newLines.push(line);
102
+ }
103
+
104
+ if (!updated) {
105
+ const formattedVal = (value.includes(' ') || value.includes('\n')) ? `"${value}"` : value;
106
+ if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
107
+ newLines.push('');
108
+ }
109
+ newLines.push(`${key}=${formattedVal}`);
110
+ }
111
+
112
+ const finalContent = newLines.join('\n');
113
+ await this.remoteFsService.writeFile(profile, filePath, finalContent);
114
+
115
+ return {
116
+ ok: true,
117
+ filePath,
118
+ key,
119
+ updated
120
+ };
121
+ }
122
+ }
@@ -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,13 @@ 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';
10
+ import { TarSyncService } from './tar-sync-service.js';
11
+ import { DiagnoseService } from './diagnose-service.js';
12
+ import { EnvService } from './env-service.js';
13
+ import { AlertService } from './alert-service.js';
7
14
  import { registerRemoteTools } from './tools.js';
8
15
  import { registerApiRoutes } from './routes.js';
9
16
 
@@ -26,9 +33,12 @@ export const Config = z.object({
26
33
  passphrase: z.string().default(''),
27
34
  password: z.string().default(''),
28
35
  remoteWorkspace: z.string().default(''),
29
- localMirrorPath: z.string().default('')
36
+ localMirrorPath: z.string().default(''),
37
+ jumpHostId: z.string().default('')
30
38
  })).default([]).description('List of configured remote machines and workspaces.'),
31
- activeProfileId: z.string().default('').description('ID of active remote profile.')
39
+ activeProfileId: z.string().default('').description('ID of active remote profile.'),
40
+ autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.'),
41
+ alertMonitoringActive: z.boolean().default(false).description('Whether background anomaly alerts are monitored.')
32
42
  });
33
43
 
34
44
  export function apply(ctx, config = {}) {
@@ -36,7 +46,9 @@ export function apply(ctx, config = {}) {
36
46
  let settingsApi = null;
37
47
  let currentConfig = {
38
48
  profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
39
- activeProfileId: cfg.activeProfileId || null
49
+ activeProfileId: cfg.activeProfileId || null,
50
+ autoSyncActive: Boolean(cfg.autoSyncActive),
51
+ alertMonitoringActive: Boolean(cfg.alertMonitoringActive)
40
52
  };
41
53
 
42
54
  const vaultService = new VaultService();
@@ -44,6 +56,13 @@ export function apply(ctx, config = {}) {
44
56
  const remoteFs = new RemoteFsService(sshService);
45
57
  const mirrorSync = new MirrorSyncService(remoteFs, sshService);
46
58
  const tunnelService = new TunnelService(sshService);
59
+ const dockerService = new DockerService(sshService);
60
+ const healthService = new HealthService(sshService);
61
+ const watcherService = new WatcherService();
62
+ const tarSyncService = new TarSyncService(sshService);
63
+ const diagnoseService = new DiagnoseService(sshService);
64
+ const envService = new EnvService(remoteFs);
65
+ const alertService = new AlertService(ctx, healthService, dockerService);
47
66
 
48
67
  // Migrate any existing plain passwords into vault upon start
49
68
  for (const p of currentConfig.profiles) {
@@ -78,11 +97,22 @@ export function apply(ctx, config = {}) {
78
97
  const p = (currentConfig.profiles || []).find((x) => x.id === currentConfig.activeProfileId);
79
98
  return p ? vaultService.hydrateProfile(p) : undefined;
80
99
  },
100
+ isAutoSyncEnabled() { return Boolean(currentConfig.autoSyncActive); },
101
+ async setAutoSyncEnabled(enabled) {
102
+ currentConfig.autoSyncActive = Boolean(enabled);
103
+ const active = store.getActiveProfile();
104
+ if (active) {
105
+ if (enabled) watcherService.start(active, mirrorSync);
106
+ else watcherService.stop(active.id);
107
+ }
108
+ if (settingsApi?.replace) {
109
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
110
+ }
111
+ },
81
112
  async saveProfile(rawP) {
82
113
  const list = [...(currentConfig.profiles || [])];
83
114
  const p = { ...rawP };
84
115
 
85
- // Save secret credentials into isolated .env file if provided
86
116
  const secretsToUpdate = {};
87
117
  if (p.password !== undefined && p.password !== '••••••••') {
88
118
  secretsToUpdate.password = p.password;
@@ -97,7 +127,6 @@ export function apply(ctx, config = {}) {
97
127
  vaultService.setProfileSecrets(p.id, secretsToUpdate);
98
128
  }
99
129
 
100
- // Do NOT keep raw secrets in public config
101
130
  p.password = '';
102
131
  p.privateKey = '';
103
132
  p.passphrase = '';
@@ -116,6 +145,7 @@ export function apply(ctx, config = {}) {
116
145
  }
117
146
  },
118
147
  async deleteProfile(id) {
148
+ watcherService.stop(id);
119
149
  currentConfig.profiles = (currentConfig.profiles || []).filter((p) => p.id !== id);
120
150
  if (currentConfig.activeProfileId === id) {
121
151
  currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
@@ -128,12 +158,25 @@ export function apply(ctx, config = {}) {
128
158
  },
129
159
  async setActiveId(id) {
130
160
  currentConfig.activeProfileId = id;
161
+ if (currentConfig.autoSyncActive) {
162
+ watcherService.stopAll();
163
+ const active = store.getActiveProfile();
164
+ if (active) watcherService.start(active, mirrorSync);
165
+ }
131
166
  if (settingsApi?.replace) {
132
167
  try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
133
168
  }
134
169
  }
135
170
  };
136
171
 
172
+ sshService.setJumpProfileResolver((jumpId) => store.getProfile(jumpId));
173
+
174
+ // If autoSync was enabled on restart, resume watcher for active profile
175
+ if (currentConfig.autoSyncActive && currentConfig.activeProfileId) {
176
+ const active = store.getActiveProfile();
177
+ if (active) watcherService.start(active, mirrorSync);
178
+ }
179
+
137
180
  // Register settings namespace if settings service is available
138
181
  ctx.inject(['settings'], (sctx) => {
139
182
  const settingsService = sctx.get ? (sctx.get('settings') ?? sctx.settings) : sctx.settings;
@@ -145,6 +188,8 @@ export function apply(ctx, config = {}) {
145
188
  if (snap) {
146
189
  if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
147
190
  if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
191
+ if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
192
+ if (snap.alertMonitoringActive !== undefined) currentConfig.alertMonitoringActive = snap.alertMonitoringActive;
148
193
  }
149
194
  sctx.effect(() => () => {
150
195
  settingsApi = null;
@@ -156,21 +201,63 @@ export function apply(ctx, config = {}) {
156
201
  ctx.provide('remoteSync');
157
202
  ctx.provide('remoteTunnel');
158
203
  ctx.provide('remoteVault');
204
+ ctx.provide('remoteDocker');
205
+ ctx.provide('remoteHealth');
206
+ ctx.provide('remoteWatcher');
207
+ ctx.provide('remoteTarSync');
208
+ ctx.provide('remoteDiagnose');
209
+ ctx.provide('remoteEnv');
210
+ ctx.provide('remoteAlert');
159
211
 
160
212
  ctx.remoteSsh = sshService;
161
213
  ctx.remoteFs = remoteFs;
162
214
  ctx.remoteSync = mirrorSync;
163
215
  ctx.remoteTunnel = tunnelService;
164
216
  ctx.remoteVault = vaultService;
217
+ ctx.remoteDocker = dockerService;
218
+ ctx.remoteHealth = healthService;
219
+ ctx.remoteWatcher = watcherService;
220
+ ctx.remoteTarSync = tarSyncService;
221
+ ctx.remoteDiagnose = diagnoseService;
222
+ ctx.remoteEnv = envService;
223
+ ctx.remoteAlert = alertService;
165
224
 
166
225
  // Tools registration
167
- registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, () => store.getActiveProfile());
226
+ registerRemoteTools(
227
+ ctx,
228
+ sshService,
229
+ remoteFs,
230
+ mirrorSync,
231
+ tunnelService,
232
+ () => store.getActiveProfile(),
233
+ (id) => store.getProfile(id),
234
+ dockerService,
235
+ diagnoseService,
236
+ envService,
237
+ tarSyncService
238
+ );
168
239
 
169
240
  // API Routes
170
- registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store);
241
+ registerApiRoutes(
242
+ ctx,
243
+ sshService,
244
+ remoteFs,
245
+ mirrorSync,
246
+ tunnelService,
247
+ store,
248
+ dockerService,
249
+ healthService,
250
+ watcherService,
251
+ diagnoseService,
252
+ envService,
253
+ tarSyncService,
254
+ alertService
255
+ );
171
256
 
172
257
  // Cleanup on unload
173
258
  ctx.on('dispose', () => {
259
+ alertService.stop();
260
+ watcherService.stopAll();
174
261
  tunnelService.stopAll();
175
262
  sshService.disconnectAll();
176
263
  });