@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.
@@ -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 fast 3-way delta synchronization between local mirror and remote server',
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.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",