@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,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
+ }
@@ -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
- * Resolve private key content either from direct string or file path
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, and auto-reconnect
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
- * Create an interactive PTY terminal session for Web UI
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 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 {
@@ -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.2.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",