@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,89 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
export class SshConfigParser {
|
|
6
|
+
/**
|
|
7
|
+
* Parse ssh config string into DSH remote profiles
|
|
8
|
+
* @param {string} text
|
|
9
|
+
* @returns {Array<any>}
|
|
10
|
+
*/
|
|
11
|
+
static parse(text) {
|
|
12
|
+
if (!text || typeof text !== 'string') return [];
|
|
13
|
+
|
|
14
|
+
const lines = text.split('\n');
|
|
15
|
+
const profiles = [];
|
|
16
|
+
let current = null;
|
|
17
|
+
|
|
18
|
+
for (let rawLine of lines) {
|
|
19
|
+
const line = rawLine.trim();
|
|
20
|
+
if (!line || line.startsWith('#')) continue;
|
|
21
|
+
|
|
22
|
+
// Key Value pair (can be separated by whitespace or '=')
|
|
23
|
+
const match = line.match(/^(\w+)(?:\s+|=)(.+)$/);
|
|
24
|
+
if (!match) continue;
|
|
25
|
+
|
|
26
|
+
const key = match[1].toLowerCase();
|
|
27
|
+
const val = match[2].trim().replace(/^["']|["']$/g, '');
|
|
28
|
+
|
|
29
|
+
if (key === 'host') {
|
|
30
|
+
// Skip wildcards like Host *
|
|
31
|
+
if (val === '*' || val.includes('*') || val.includes('?')) {
|
|
32
|
+
current = null;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (current && current.host) {
|
|
37
|
+
profiles.push(current);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
current = {
|
|
41
|
+
id: `ssh-cfg-${val.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`,
|
|
42
|
+
name: val,
|
|
43
|
+
host: val,
|
|
44
|
+
port: 22,
|
|
45
|
+
username: os.userInfo().username || 'root',
|
|
46
|
+
authType: 'key',
|
|
47
|
+
privateKey: '~/.ssh/id_ed25519',
|
|
48
|
+
remoteProjectDir: '/root',
|
|
49
|
+
jumpHostId: ''
|
|
50
|
+
};
|
|
51
|
+
} else if (current) {
|
|
52
|
+
if (key === 'hostname') {
|
|
53
|
+
current.host = val;
|
|
54
|
+
} else if (key === 'user') {
|
|
55
|
+
current.username = val;
|
|
56
|
+
} else if (key === 'port') {
|
|
57
|
+
const p = parseInt(val, 10);
|
|
58
|
+
if (p > 0 && p < 65536) current.port = p;
|
|
59
|
+
} else if (key === 'identityfile') {
|
|
60
|
+
current.authType = 'key';
|
|
61
|
+
current.privateKey = val;
|
|
62
|
+
} else if (key === 'proxyjump') {
|
|
63
|
+
current.jumpHostId = val;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (current && current.host) {
|
|
69
|
+
profiles.push(current);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return profiles;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Load local default ~/.ssh/config if exists
|
|
77
|
+
*/
|
|
78
|
+
static readDefaultConfig() {
|
|
79
|
+
const configPath = path.join(os.homedir(), '.ssh', 'config');
|
|
80
|
+
if (fs.existsSync(configPath)) {
|
|
81
|
+
try {
|
|
82
|
+
return fs.readFileSync(configPath, 'utf8');
|
|
83
|
+
} catch {
|
|
84
|
+
return '';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return '';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
|
|
5
|
+
export class TarSyncService {
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('./ssh-service.js').SshService} sshService
|
|
8
|
+
*/
|
|
9
|
+
constructor(sshService) {
|
|
10
|
+
this.sshService = sshService;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Stream tar archive from Remote to Local directory
|
|
15
|
+
* Remote runs: tar -czf - -C <remoteDir> .
|
|
16
|
+
* Local receives stream and runs: tar -xzf - -C <localDir>
|
|
17
|
+
*/
|
|
18
|
+
async pullTar(profile, remoteDir, localDir) {
|
|
19
|
+
if (!profile) throw new Error('No profile provided');
|
|
20
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
21
|
+
|
|
22
|
+
const client = await this.sshService.getConnection(profile);
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const remoteCmd = `tar --exclude='.git' --exclude='node_modules' -czf - -C "${remoteDir}" .`;
|
|
25
|
+
client.exec(remoteCmd, (err, stream) => {
|
|
26
|
+
if (err) return reject(err);
|
|
27
|
+
|
|
28
|
+
const localTar = spawn('tar', ['-xzf', '-', '-C', localDir]);
|
|
29
|
+
let stderr = '';
|
|
30
|
+
localTar.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
31
|
+
|
|
32
|
+
stream.pipe(localTar.stdin);
|
|
33
|
+
|
|
34
|
+
localTar.on('close', (code) => {
|
|
35
|
+
if (code !== 0) {
|
|
36
|
+
reject(new Error(`Local tar extraction failed (code ${code}): ${stderr}`));
|
|
37
|
+
} else {
|
|
38
|
+
resolve({ ok: true, direction: 'pull', remoteDir, localDir });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
stream.on('error', reject);
|
|
43
|
+
localTar.stdin.on('error', reject);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Stream tar archive from Local to Remote directory
|
|
50
|
+
* Local runs: tar -czf - -C <localDir> .
|
|
51
|
+
* Remote receives stream and runs: tar -xzf - -C <remoteDir>
|
|
52
|
+
*/
|
|
53
|
+
async pushTar(profile, localDir, remoteDir) {
|
|
54
|
+
if (!profile) throw new Error('No profile provided');
|
|
55
|
+
if (!fs.existsSync(localDir)) throw new Error(`Local dir does not exist: ${localDir}`);
|
|
56
|
+
|
|
57
|
+
const client = await this.sshService.getConnection(profile);
|
|
58
|
+
// Ensure remoteDir exists
|
|
59
|
+
await this.sshService.exec(profile, `mkdir -p "${remoteDir}"`);
|
|
60
|
+
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const remoteCmd = `tar -xzf - -C "${remoteDir}"`;
|
|
63
|
+
client.exec(remoteCmd, (err, stream) => {
|
|
64
|
+
if (err) return reject(err);
|
|
65
|
+
|
|
66
|
+
const localTar = spawn('tar', ['--exclude=.git', '--exclude=node_modules', '-czf', '-', '-C', localDir, '.']);
|
|
67
|
+
let remoteStderr = '';
|
|
68
|
+
stream.stderr.on('data', (d) => { remoteStderr += d.toString(); });
|
|
69
|
+
|
|
70
|
+
localTar.stdout.pipe(stream);
|
|
71
|
+
|
|
72
|
+
stream.on('close', (code) => {
|
|
73
|
+
if (code !== 0) {
|
|
74
|
+
reject(new Error(`Remote tar extraction failed (code ${code}): ${remoteStderr}`));
|
|
75
|
+
} else {
|
|
76
|
+
resolve({ ok: true, direction: 'push', localDir, remoteDir });
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
stream.on('error', reject);
|
|
81
|
+
localTar.on('error', reject);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
package/lib/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService) {
|
|
1
|
+
export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, getActiveProfile, getProfileById, dockerService, diagnoseService, envService, tarSyncService) {
|
|
2
2
|
if (!ctx.tools || typeof ctx.tools.register !== 'function') {
|
|
3
3
|
return;
|
|
4
4
|
}
|
|
@@ -96,9 +96,10 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
96
96
|
// 3. remote_sync
|
|
97
97
|
ctx.tools.register({
|
|
98
98
|
name: 'remote_sync',
|
|
99
|
-
description: 'Perform
|
|
99
|
+
description: 'Perform 3-way delta or streaming tarball synchronization between local mirror and remote server',
|
|
100
100
|
parameters: {
|
|
101
101
|
direction: { type: 'string', required: true, description: 'pull (remote -> local) or push (local -> remote)' },
|
|
102
|
+
mode: { type: 'string', description: 'sync mode: delta (default) or tarball (fast batch stream)' },
|
|
102
103
|
force: { type: 'boolean', description: 'Overwrite conflicts if true (use with caution)' },
|
|
103
104
|
dryRun: { type: 'boolean', description: 'Simulate changes without writing files' }
|
|
104
105
|
},
|
|
@@ -110,6 +111,14 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
110
111
|
throw new Error('Both remoteWorkspace and localMirrorPath must be configured');
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
if (args.mode === 'tarball' && tarSyncService) {
|
|
115
|
+
if (args.direction === 'pull') {
|
|
116
|
+
return await tarSyncService.pullTar(profile, profile.remoteWorkspace, profile.localMirrorPath);
|
|
117
|
+
} else {
|
|
118
|
+
return await tarSyncService.pushTar(profile, profile.localMirrorPath, profile.remoteWorkspace);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
113
122
|
if (args.direction === 'pull') {
|
|
114
123
|
return await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath, args.dryRun);
|
|
115
124
|
} else {
|
|
@@ -212,7 +221,6 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
212
221
|
if (args.action === 'logs') cmd = `pm2 logs ${JSON.stringify(svc)} --lines ${lines} --nostream`;
|
|
213
222
|
else cmd = `pm2 ${args.action} ${JSON.stringify(svc)}`;
|
|
214
223
|
} else {
|
|
215
|
-
// systemd
|
|
216
224
|
if (args.action === 'logs') cmd = `journalctl -u ${JSON.stringify(svc)} -n ${lines} --no-pager`;
|
|
217
225
|
else if (args.action === 'status') cmd = `systemctl status ${JSON.stringify(svc)} --no-pager`;
|
|
218
226
|
else cmd = `sudo systemctl ${args.action} ${JSON.stringify(svc)} || systemctl ${args.action} ${JSON.stringify(svc)}`;
|
|
@@ -247,4 +255,71 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
247
255
|
return await sshService.transferFileBetweenHosts(srcProfile, args.sourcePath, destProfile, args.destPath);
|
|
248
256
|
}
|
|
249
257
|
});
|
|
258
|
+
|
|
259
|
+
// 8. remote_diagnose (New in v0.3.1)
|
|
260
|
+
if (diagnoseService) {
|
|
261
|
+
ctx.tools.register({
|
|
262
|
+
name: 'remote_diagnose',
|
|
263
|
+
description: 'Run targeted system health diagnostics on remote host (ports, disk, memory, oom_killer, service_logs)',
|
|
264
|
+
parameters: {
|
|
265
|
+
category: {
|
|
266
|
+
type: 'string',
|
|
267
|
+
required: true,
|
|
268
|
+
description: 'Diagnostic aspect: ports, disk, memory, oom_killer, service_logs'
|
|
269
|
+
},
|
|
270
|
+
target: {
|
|
271
|
+
type: 'string',
|
|
272
|
+
description: 'Optional port number, directory path, or service name to inspect'
|
|
273
|
+
}
|
|
274
|
+
},
|
|
275
|
+
output: genericOutput,
|
|
276
|
+
async execute(args) {
|
|
277
|
+
const profile = getActiveProfile();
|
|
278
|
+
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
279
|
+
return await diagnoseService.diagnose(profile, args.category, args.target);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 9. remote_env (New in v0.3.1)
|
|
285
|
+
if (envService) {
|
|
286
|
+
ctx.tools.register({
|
|
287
|
+
name: 'remote_env',
|
|
288
|
+
description: 'Inspect and atomically update remote .env files preserving comments and structure',
|
|
289
|
+
parameters: {
|
|
290
|
+
action: {
|
|
291
|
+
type: 'string',
|
|
292
|
+
required: true,
|
|
293
|
+
description: 'Action to perform: read or set'
|
|
294
|
+
},
|
|
295
|
+
filePath: {
|
|
296
|
+
type: 'string',
|
|
297
|
+
required: true,
|
|
298
|
+
description: 'Path to the .env file on the remote server'
|
|
299
|
+
},
|
|
300
|
+
key: {
|
|
301
|
+
type: 'string',
|
|
302
|
+
description: 'Environment variable key (required for set)'
|
|
303
|
+
},
|
|
304
|
+
value: {
|
|
305
|
+
type: 'string',
|
|
306
|
+
description: 'Environment variable value (for set action)'
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
output: genericOutput,
|
|
310
|
+
async execute(args) {
|
|
311
|
+
const profile = getActiveProfile();
|
|
312
|
+
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
313
|
+
|
|
314
|
+
if (args.action === 'read') {
|
|
315
|
+
return await envService.readEnv(profile, args.filePath);
|
|
316
|
+
} else if (args.action === 'set') {
|
|
317
|
+
if (!args.key) throw new Error('key is required for set action');
|
|
318
|
+
return await envService.setEnvVar(profile, args.filePath, args.key, args.value || '');
|
|
319
|
+
} else {
|
|
320
|
+
throw new Error(`Unknown env action: ${args.action}`);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
}
|
|
250
325
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-remote-workspace",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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",
|