@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.
- 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 +490 -77
- package/lib/diagnose-service.js +57 -0
- package/lib/docker-service.js +93 -0
- package/lib/env-service.js +122 -0
- package/lib/health-service.js +117 -0
- package/lib/index.js +94 -7
- package/lib/routes.js +627 -398
- package/lib/ssh-config-parser.js +89 -0
- package/lib/ssh-service.js +59 -20
- package/lib/tar-sync-service.js +85 -0
- package/lib/tools.js +175 -2
- package/lib/watcher-service.js +111 -0
- 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
|
+
}
|
package/lib/ssh-service.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
*
|
|
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;
|
|
@@ -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) {
|
|
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 {
|
|
@@ -149,4 +158,168 @@ export function registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunne
|
|
|
149
158
|
}
|
|
150
159
|
}
|
|
151
160
|
});
|
|
161
|
+
|
|
162
|
+
// 5. remote_docker
|
|
163
|
+
if (dockerService) {
|
|
164
|
+
ctx.tools.register({
|
|
165
|
+
name: 'remote_docker',
|
|
166
|
+
description: 'Manage Docker/Podman containers and Docker Compose on remote host',
|
|
167
|
+
parameters: {
|
|
168
|
+
action: { type: 'string', required: true, description: 'list, logs, start, stop, restart, compose' },
|
|
169
|
+
containerId: { type: 'string', description: 'Container ID or name' },
|
|
170
|
+
tail: { type: 'number', description: 'Number of log lines to retrieve (default 50)' },
|
|
171
|
+
composeAction: { type: 'string', description: 'Compose action: up -d, down, ps, restart' },
|
|
172
|
+
composeCwd: { type: 'string', description: 'Directory containing docker-compose.yml' }
|
|
173
|
+
},
|
|
174
|
+
output: genericOutput,
|
|
175
|
+
async execute(args) {
|
|
176
|
+
const profile = getActiveProfile();
|
|
177
|
+
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
178
|
+
|
|
179
|
+
switch (args.action) {
|
|
180
|
+
case 'list':
|
|
181
|
+
return await dockerService.listContainers(profile, true);
|
|
182
|
+
case 'logs':
|
|
183
|
+
if (!args.containerId) throw new Error('containerId required for logs');
|
|
184
|
+
return await dockerService.containerLogs(profile, args.containerId, args.tail || 50);
|
|
185
|
+
case 'start':
|
|
186
|
+
case 'stop':
|
|
187
|
+
case 'restart':
|
|
188
|
+
if (!args.containerId) throw new Error(`containerId required for ${args.action}`);
|
|
189
|
+
return await dockerService.containerAction(profile, args.containerId, args.action);
|
|
190
|
+
case 'compose':
|
|
191
|
+
const dir = args.composeCwd || profile.remoteWorkspace;
|
|
192
|
+
return await dockerService.composeAction(profile, dir, args.composeAction || 'ps');
|
|
193
|
+
default:
|
|
194
|
+
throw new Error(`Unknown docker action: ${args.action}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// 6. remote_service
|
|
201
|
+
ctx.tools.register({
|
|
202
|
+
name: 'remote_service',
|
|
203
|
+
description: 'Inspect and manage Linux systemd or PM2 services on the remote machine',
|
|
204
|
+
parameters: {
|
|
205
|
+
action: { type: 'string', required: true, description: 'status, start, stop, restart, logs' },
|
|
206
|
+
service: { type: 'string', required: true, description: 'Service name (e.g. nginx, redis, my-app)' },
|
|
207
|
+
manager: { type: 'string', description: 'systemd (default) or pm2' },
|
|
208
|
+
lines: { type: 'number', description: 'Number of log lines for logs action (default 40)' }
|
|
209
|
+
},
|
|
210
|
+
output: genericOutput,
|
|
211
|
+
async execute(args) {
|
|
212
|
+
const profile = getActiveProfile();
|
|
213
|
+
if (!profile) throw new Error('No active remote workspace profile selected');
|
|
214
|
+
|
|
215
|
+
const mgr = args.manager || 'systemd';
|
|
216
|
+
const svc = args.service;
|
|
217
|
+
const lines = args.lines || 40;
|
|
218
|
+
|
|
219
|
+
let cmd = '';
|
|
220
|
+
if (mgr === 'pm2') {
|
|
221
|
+
if (args.action === 'logs') cmd = `pm2 logs ${JSON.stringify(svc)} --lines ${lines} --nostream`;
|
|
222
|
+
else cmd = `pm2 ${args.action} ${JSON.stringify(svc)}`;
|
|
223
|
+
} else {
|
|
224
|
+
if (args.action === 'logs') cmd = `journalctl -u ${JSON.stringify(svc)} -n ${lines} --no-pager`;
|
|
225
|
+
else if (args.action === 'status') cmd = `systemctl status ${JSON.stringify(svc)} --no-pager`;
|
|
226
|
+
else cmd = `sudo systemctl ${args.action} ${JSON.stringify(svc)} || systemctl ${args.action} ${JSON.stringify(svc)}`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const res = await sshService.exec(profile, cmd, { cleanAnsi: true });
|
|
230
|
+
return {
|
|
231
|
+
exitCode: res.code,
|
|
232
|
+
output: res.stdout || res.stderr
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// 7. remote_transfer
|
|
238
|
+
ctx.tools.register({
|
|
239
|
+
name: 'remote_transfer',
|
|
240
|
+
description: 'Transfer a file directly between two configured remote servers via in-memory SFTP streams',
|
|
241
|
+
parameters: {
|
|
242
|
+
sourcePath: { type: 'string', required: true, description: 'File path on the source remote server' },
|
|
243
|
+
destPath: { type: 'string', required: true, description: 'File path on the destination remote server' },
|
|
244
|
+
destProfileId: { type: 'string', required: true, description: 'Target remote profile ID' },
|
|
245
|
+
sourceProfileId: { type: 'string', description: 'Source remote profile ID (defaults to active profile)' }
|
|
246
|
+
},
|
|
247
|
+
output: genericOutput,
|
|
248
|
+
async execute(args) {
|
|
249
|
+
const srcProfile = args.sourceProfileId && getProfileById ? getProfileById(args.sourceProfileId) : getActiveProfile();
|
|
250
|
+
if (!srcProfile) throw new Error('Source profile not found or no active profile');
|
|
251
|
+
|
|
252
|
+
const destProfile = getProfileById ? getProfileById(args.destProfileId) : null;
|
|
253
|
+
if (!destProfile) throw new Error(`Destination profile "${args.destProfileId}" not found`);
|
|
254
|
+
|
|
255
|
+
return await sshService.transferFileBetweenHosts(srcProfile, args.sourcePath, destProfile, args.destPath);
|
|
256
|
+
}
|
|
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
|
+
}
|
|
152
325
|
}
|
|
@@ -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.
|
|
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",
|