@goodandready/dsh-remote-workspace 0.3.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.
- package/README.md +17 -0
- package/README.ru.md +17 -0
- package/README.zh.md +17 -0
- package/docs/plans/task_plan.md +18 -20
- package/lib/alert-service.js +109 -0
- package/lib/client.js +265 -77
- package/lib/diagnose-service.js +57 -0
- package/lib/env-service.js +122 -0
- package/lib/index.js +41 -4
- package/lib/routes.js +627 -522
- package/lib/ssh-config-parser.js +89 -0
- package/lib/tar-sync-service.js +85 -0
- package/lib/tools.js +78 -3
- package/package.json +1 -1
|
@@ -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,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
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -7,6 +7,10 @@ import { TunnelService } from './tunnel-service.js';
|
|
|
7
7
|
import { DockerService } from './docker-service.js';
|
|
8
8
|
import { HealthService } from './health-service.js';
|
|
9
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';
|
|
10
14
|
import { registerRemoteTools } from './tools.js';
|
|
11
15
|
import { registerApiRoutes } from './routes.js';
|
|
12
16
|
|
|
@@ -33,7 +37,8 @@ export const Config = z.object({
|
|
|
33
37
|
jumpHostId: z.string().default('')
|
|
34
38
|
})).default([]).description('List of configured remote machines and workspaces.'),
|
|
35
39
|
activeProfileId: z.string().default('').description('ID of active remote profile.'),
|
|
36
|
-
autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.')
|
|
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.')
|
|
37
42
|
});
|
|
38
43
|
|
|
39
44
|
export function apply(ctx, config = {}) {
|
|
@@ -42,7 +47,8 @@ export function apply(ctx, config = {}) {
|
|
|
42
47
|
let currentConfig = {
|
|
43
48
|
profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
|
|
44
49
|
activeProfileId: cfg.activeProfileId || null,
|
|
45
|
-
autoSyncActive: Boolean(cfg.autoSyncActive)
|
|
50
|
+
autoSyncActive: Boolean(cfg.autoSyncActive),
|
|
51
|
+
alertMonitoringActive: Boolean(cfg.alertMonitoringActive)
|
|
46
52
|
};
|
|
47
53
|
|
|
48
54
|
const vaultService = new VaultService();
|
|
@@ -53,6 +59,10 @@ export function apply(ctx, config = {}) {
|
|
|
53
59
|
const dockerService = new DockerService(sshService);
|
|
54
60
|
const healthService = new HealthService(sshService);
|
|
55
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);
|
|
56
66
|
|
|
57
67
|
// Migrate any existing plain passwords into vault upon start
|
|
58
68
|
for (const p of currentConfig.profiles) {
|
|
@@ -179,6 +189,7 @@ export function apply(ctx, config = {}) {
|
|
|
179
189
|
if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
|
|
180
190
|
if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
|
|
181
191
|
if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
|
|
192
|
+
if (snap.alertMonitoringActive !== undefined) currentConfig.alertMonitoringActive = snap.alertMonitoringActive;
|
|
182
193
|
}
|
|
183
194
|
sctx.effect(() => () => {
|
|
184
195
|
settingsApi = null;
|
|
@@ -193,6 +204,10 @@ export function apply(ctx, config = {}) {
|
|
|
193
204
|
ctx.provide('remoteDocker');
|
|
194
205
|
ctx.provide('remoteHealth');
|
|
195
206
|
ctx.provide('remoteWatcher');
|
|
207
|
+
ctx.provide('remoteTarSync');
|
|
208
|
+
ctx.provide('remoteDiagnose');
|
|
209
|
+
ctx.provide('remoteEnv');
|
|
210
|
+
ctx.provide('remoteAlert');
|
|
196
211
|
|
|
197
212
|
ctx.remoteSsh = sshService;
|
|
198
213
|
ctx.remoteFs = remoteFs;
|
|
@@ -202,6 +217,10 @@ export function apply(ctx, config = {}) {
|
|
|
202
217
|
ctx.remoteDocker = dockerService;
|
|
203
218
|
ctx.remoteHealth = healthService;
|
|
204
219
|
ctx.remoteWatcher = watcherService;
|
|
220
|
+
ctx.remoteTarSync = tarSyncService;
|
|
221
|
+
ctx.remoteDiagnose = diagnoseService;
|
|
222
|
+
ctx.remoteEnv = envService;
|
|
223
|
+
ctx.remoteAlert = alertService;
|
|
205
224
|
|
|
206
225
|
// Tools registration
|
|
207
226
|
registerRemoteTools(
|
|
@@ -212,14 +231,32 @@ export function apply(ctx, config = {}) {
|
|
|
212
231
|
tunnelService,
|
|
213
232
|
() => store.getActiveProfile(),
|
|
214
233
|
(id) => store.getProfile(id),
|
|
215
|
-
dockerService
|
|
234
|
+
dockerService,
|
|
235
|
+
diagnoseService,
|
|
236
|
+
envService,
|
|
237
|
+
tarSyncService
|
|
216
238
|
);
|
|
217
239
|
|
|
218
240
|
// API Routes
|
|
219
|
-
registerApiRoutes(
|
|
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
|
+
);
|
|
220
256
|
|
|
221
257
|
// Cleanup on unload
|
|
222
258
|
ctx.on('dispose', () => {
|
|
259
|
+
alertService.stop();
|
|
223
260
|
watcherService.stopAll();
|
|
224
261
|
tunnelService.stopAll();
|
|
225
262
|
sshService.disconnectAll();
|