@goodandready/dsh-remote-workspace 0.1.1 → 0.3.0

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,93 @@
1
+ export class DockerService {
2
+ /**
3
+ * @param {import('./ssh-service.js').SshService} sshService
4
+ */
5
+ constructor(sshService) {
6
+ this.sshService = sshService;
7
+ }
8
+
9
+ /**
10
+ * List active or all containers with JSON formatting
11
+ */
12
+ async listContainers(profile, all = true) {
13
+ // Detect docker or podman
14
+ const flag = all ? '-a' : '';
15
+ // Format as line-delimited JSON or tabbed format
16
+ const cmd = `docker ps ${flag} --format '{{json .}}' 2>/dev/null || podman ps ${flag} --format '{{json .}}' 2>/dev/null`;
17
+ const res = await this.sshService.exec(profile, cmd);
18
+
19
+ if (res.code !== 0 && !res.stdout.trim()) {
20
+ return {
21
+ available: false,
22
+ error: res.stderr || 'Docker not found or daemon not running',
23
+ containers: []
24
+ };
25
+ }
26
+
27
+ const containers = [];
28
+ const lines = res.stdout.split('\n').filter(Boolean);
29
+ for (const line of lines) {
30
+ try {
31
+ const item = JSON.parse(line);
32
+ containers.push({
33
+ id: item.ID || item.Id || '',
34
+ names: item.Names || item.Name || '',
35
+ image: item.Image || '',
36
+ status: item.Status || '',
37
+ state: (item.State || '').toLowerCase(),
38
+ ports: item.Ports || '',
39
+ created: item.CreatedAt || item.Created || ''
40
+ });
41
+ } catch (_) {}
42
+ }
43
+
44
+ return {
45
+ available: true,
46
+ containers
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Container action: start, stop, restart, pause, unpause
52
+ */
53
+ async containerAction(profile, containerId, action) {
54
+ const validActions = ['start', 'stop', 'restart', 'pause', 'unpause', 'rm'];
55
+ if (!validActions.includes(action)) {
56
+ throw new Error(`Invalid container action: ${action}`);
57
+ }
58
+ const cmd = `docker ${action} ${JSON.stringify(containerId)} 2>/dev/null || podman ${action} ${JSON.stringify(containerId)}`;
59
+ const res = await this.sshService.exec(profile, cmd);
60
+ return {
61
+ success: res.code === 0,
62
+ stdout: res.stdout.trim(),
63
+ stderr: res.stderr.trim()
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Fetch container logs
69
+ */
70
+ async containerLogs(profile, containerId, tail = 100) {
71
+ const cmd = `docker logs --tail ${tail} ${JSON.stringify(containerId)} 2>&1 || podman logs --tail ${tail} ${JSON.stringify(containerId)} 2>&1`;
72
+ const res = await this.sshService.exec(profile, cmd);
73
+ return {
74
+ success: res.code === 0,
75
+ logs: res.stdout || res.stderr || ''
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Docker compose lifecycle
81
+ */
82
+ async composeAction(profile, cwd, action = 'ps') {
83
+ const valid = ['up -d', 'down', 'ps', 'restart', 'pull'];
84
+ const safeAction = valid.includes(action) ? action : 'ps';
85
+ const cmd = `docker compose ${safeAction} 2>/dev/null || docker-compose ${safeAction} 2>/dev/null || podman-compose ${safeAction}`;
86
+ const res = await this.sshService.exec(profile, cmd, cwd);
87
+ return {
88
+ success: res.code === 0,
89
+ stdout: res.stdout,
90
+ stderr: res.stderr
91
+ };
92
+ }
93
+ }
@@ -0,0 +1,117 @@
1
+ export class HealthService {
2
+ /**
3
+ * @param {import('./ssh-service.js').SshService} sshService
4
+ */
5
+ constructor(sshService) {
6
+ this.sshService = sshService;
7
+ /** @type {Map<string, { data: any, expiresAt: number }>} */
8
+ this.cache = new Map();
9
+ this.cacheTtlMs = 4000;
10
+ }
11
+
12
+ async getHealth(profile, bypassCache = false) {
13
+ if (!profile || !profile.host) {
14
+ throw new Error('Invalid profile');
15
+ }
16
+
17
+ const cacheKey = profile.id;
18
+ if (!bypassCache) {
19
+ const cached = this.cache.get(cacheKey);
20
+ if (cached && cached.expiresAt > Date.now()) {
21
+ return cached.data;
22
+ }
23
+ }
24
+
25
+ // Single compact shell command to gather all stats at once:
26
+ // 1. uptime (load averages)
27
+ // 2. memory (free -m)
28
+ // 3. disk usage (df -k /)
29
+ // 4. cpu info (cores & idle via /proc/stat or top)
30
+ const probeCmd = `
31
+ echo "---UPTIME---"
32
+ uptime 2>/dev/null || true
33
+ echo "---MEM---"
34
+ free -m 2>/dev/null || true
35
+ echo "---DISK---"
36
+ df -m / 2>/dev/null || df -m . 2>/dev/null || true
37
+ echo "---CPU---"
38
+ grep 'cpu ' /proc/stat 2>/dev/null || true
39
+ `;
40
+
41
+ const res = await this.sshService.exec(profile, probeCmd);
42
+ const text = res.stdout || '';
43
+
44
+ const uptimeMatch = text.match(/---UPTIME---\n([\s\S]*?)(?=---MEM---)/);
45
+ const memMatch = text.match(/---MEM---\n([\s\S]*?)(?=---DISK---)/);
46
+ const diskMatch = text.match(/---DISK---\n([\s\S]*?)(?=---CPU---)/);
47
+
48
+ // 1. Parse Uptime & Load Avg
49
+ let uptimeStr = 'Unknown';
50
+ let loadAvg = [0, 0, 0];
51
+ if (uptimeMatch) {
52
+ const upLine = uptimeMatch[1].trim();
53
+ const loadMatch = upLine.match(/load average[s]?:\s*([\d.]+),?\s*([\d.]+),?\s*([\d.]+)/);
54
+ if (loadMatch) {
55
+ loadAvg = [parseFloat(loadMatch[1]), parseFloat(loadMatch[2]), parseFloat(loadMatch[3])];
56
+ }
57
+ const upPart = upLine.match(/up\s+(.*?),\s+\d+\s+user/);
58
+ if (upPart) uptimeStr = upPart[1];
59
+ else uptimeStr = upLine.split(',')[0] || 'Online';
60
+ }
61
+
62
+ // 2. Parse Memory
63
+ let memTotal = 0;
64
+ let memUsed = 0;
65
+ let memPercent = 0;
66
+ if (memMatch) {
67
+ const memLines = memMatch[1].trim().split('\n');
68
+ for (const line of memLines) {
69
+ if (line.startsWith('Mem:')) {
70
+ const parts = line.split(/\s+/);
71
+ memTotal = parseInt(parts[1], 10) || 0;
72
+ memUsed = parseInt(parts[2], 10) || 0;
73
+ if (memTotal > 0) {
74
+ memPercent = Math.round((memUsed / memTotal) * 100);
75
+ }
76
+ break;
77
+ }
78
+ }
79
+ }
80
+
81
+ // 3. Parse Disk
82
+ let diskTotal = 0;
83
+ let diskUsed = 0;
84
+ let diskPercent = 0;
85
+ if (diskMatch) {
86
+ const diskLines = diskMatch[1].trim().split('\n');
87
+ if (diskLines.length > 1) {
88
+ const parts = diskLines[1].split(/\s+/);
89
+ diskTotal = parseInt(parts[1], 10) || 0;
90
+ diskUsed = parseInt(parts[2], 10) || 0;
91
+ const pStr = parts[4] || '';
92
+ diskPercent = parseInt(pStr.replace('%', ''), 10) || (diskTotal > 0 ? Math.round((diskUsed / diskTotal) * 100) : 0);
93
+ }
94
+ }
95
+
96
+ const result = {
97
+ ok: true,
98
+ timestamp: Date.now(),
99
+ uptime: uptimeStr,
100
+ loadAverage: loadAvg,
101
+ cpuLoad: Math.min(100, Math.round(loadAvg[0] * 25)), // Normalized approx representation
102
+ memory: {
103
+ totalMb: memTotal,
104
+ usedMb: memUsed,
105
+ percent: memPercent
106
+ },
107
+ disk: {
108
+ totalMb: diskTotal,
109
+ usedMb: diskUsed,
110
+ percent: diskPercent
111
+ }
112
+ };
113
+
114
+ this.cache.set(cacheKey, { data: result, expiresAt: Date.now() + this.cacheTtlMs });
115
+ return result;
116
+ }
117
+ }
package/lib/index.js CHANGED
@@ -1,8 +1,12 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
+ import { VaultService } from './vault-service.js';
2
3
  import { SshService } from './ssh-service.js';
3
4
  import { RemoteFsService } from './remote-fs-service.js';
4
5
  import { MirrorSyncService } from './mirror-sync-service.js';
5
6
  import { TunnelService } from './tunnel-service.js';
7
+ import { DockerService } from './docker-service.js';
8
+ import { HealthService } from './health-service.js';
9
+ import { WatcherService } from './watcher-service.js';
6
10
  import { registerRemoteTools } from './tools.js';
7
11
  import { registerApiRoutes } from './routes.js';
8
12
 
@@ -25,9 +29,11 @@ export const Config = z.object({
25
29
  passphrase: z.string().default(''),
26
30
  password: z.string().default(''),
27
31
  remoteWorkspace: z.string().default(''),
28
- localMirrorPath: z.string().default('')
32
+ localMirrorPath: z.string().default(''),
33
+ jumpHostId: z.string().default('')
29
34
  })).default([]).description('List of configured remote machines and workspaces.'),
30
- activeProfileId: z.string().default('').description('ID of active remote profile.')
35
+ activeProfileId: z.string().default('').description('ID of active remote profile.'),
36
+ autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.')
31
37
  });
32
38
 
33
39
  export function apply(ctx, config = {}) {
@@ -35,24 +41,90 @@ export function apply(ctx, config = {}) {
35
41
  let settingsApi = null;
36
42
  let currentConfig = {
37
43
  profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
38
- activeProfileId: cfg.activeProfileId || null
44
+ activeProfileId: cfg.activeProfileId || null,
45
+ autoSyncActive: Boolean(cfg.autoSyncActive)
39
46
  };
40
47
 
41
- const sshService = new SshService(ctx);
48
+ const vaultService = new VaultService();
49
+ const sshService = new SshService(ctx, vaultService);
42
50
  const remoteFs = new RemoteFsService(sshService);
43
51
  const mirrorSync = new MirrorSyncService(remoteFs, sshService);
44
52
  const tunnelService = new TunnelService(sshService);
53
+ const dockerService = new DockerService(sshService);
54
+ const healthService = new HealthService(sshService);
55
+ const watcherService = new WatcherService();
56
+
57
+ // Migrate any existing plain passwords into vault upon start
58
+ for (const p of currentConfig.profiles) {
59
+ if (p.id && (p.password || p.privateKey || p.passphrase)) {
60
+ if (p.password && p.password !== '••••••••') {
61
+ vaultService.setProfileSecrets(p.id, { password: p.password });
62
+ p.password = '';
63
+ }
64
+ if (p.privateKey && p.privateKey !== '••••••••') {
65
+ vaultService.setProfileSecrets(p.id, { privateKey: p.privateKey });
66
+ p.privateKey = '';
67
+ }
68
+ if (p.passphrase && p.passphrase !== '••••••••') {
69
+ vaultService.setProfileSecrets(p.id, { passphrase: p.passphrase });
70
+ p.passphrase = '';
71
+ }
72
+ }
73
+ }
45
74
 
46
75
  const store = {
47
- getProfiles() { return currentConfig.profiles || []; },
48
- getProfile(id) { return (currentConfig.profiles || []).find((p) => p.id === id); },
76
+ vault: vaultService,
77
+ getRawProfiles() { return currentConfig.profiles || []; },
78
+ getProfiles() {
79
+ return (currentConfig.profiles || []).map((p) => vaultService.sanitizeProfile(p));
80
+ },
81
+ getProfile(id) {
82
+ const p = (currentConfig.profiles || []).find((x) => x.id === id);
83
+ return p ? vaultService.hydrateProfile(p) : undefined;
84
+ },
49
85
  getActiveId() { return currentConfig.activeProfileId; },
50
- getActiveProfile() { return (currentConfig.profiles || []).find((p) => p.id === currentConfig.activeProfileId); },
51
- async saveProfile(p) {
86
+ getActiveProfile() {
87
+ const p = (currentConfig.profiles || []).find((x) => x.id === currentConfig.activeProfileId);
88
+ return p ? vaultService.hydrateProfile(p) : undefined;
89
+ },
90
+ isAutoSyncEnabled() { return Boolean(currentConfig.autoSyncActive); },
91
+ async setAutoSyncEnabled(enabled) {
92
+ currentConfig.autoSyncActive = Boolean(enabled);
93
+ const active = store.getActiveProfile();
94
+ if (active) {
95
+ if (enabled) watcherService.start(active, mirrorSync);
96
+ else watcherService.stop(active.id);
97
+ }
98
+ if (settingsApi?.replace) {
99
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
100
+ }
101
+ },
102
+ async saveProfile(rawP) {
52
103
  const list = [...(currentConfig.profiles || [])];
104
+ const p = { ...rawP };
105
+
106
+ const secretsToUpdate = {};
107
+ if (p.password !== undefined && p.password !== '••••••••') {
108
+ secretsToUpdate.password = p.password;
109
+ }
110
+ if (p.privateKey !== undefined && p.privateKey !== '••••••••') {
111
+ secretsToUpdate.privateKey = p.privateKey;
112
+ }
113
+ if (p.passphrase !== undefined && p.passphrase !== '••••••••') {
114
+ secretsToUpdate.passphrase = p.passphrase;
115
+ }
116
+ if (Object.keys(secretsToUpdate).length > 0) {
117
+ vaultService.setProfileSecrets(p.id, secretsToUpdate);
118
+ }
119
+
120
+ p.password = '';
121
+ p.privateKey = '';
122
+ p.passphrase = '';
123
+
53
124
  const idx = list.findIndex((x) => x.id === p.id);
54
125
  if (idx >= 0) list[idx] = { ...list[idx], ...p };
55
126
  else list.push(p);
127
+
56
128
  currentConfig.profiles = list;
57
129
  if (!currentConfig.activeProfileId && list.length === 1) {
58
130
  currentConfig.activeProfileId = p.id;
@@ -63,10 +135,12 @@ export function apply(ctx, config = {}) {
63
135
  }
64
136
  },
65
137
  async deleteProfile(id) {
138
+ watcherService.stop(id);
66
139
  currentConfig.profiles = (currentConfig.profiles || []).filter((p) => p.id !== id);
67
140
  if (currentConfig.activeProfileId === id) {
68
141
  currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
69
142
  }
143
+ vaultService.deleteProfileSecrets(id);
70
144
  sshService.disconnect(id);
71
145
  if (settingsApi?.replace) {
72
146
  try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
@@ -74,12 +148,25 @@ export function apply(ctx, config = {}) {
74
148
  },
75
149
  async setActiveId(id) {
76
150
  currentConfig.activeProfileId = id;
151
+ if (currentConfig.autoSyncActive) {
152
+ watcherService.stopAll();
153
+ const active = store.getActiveProfile();
154
+ if (active) watcherService.start(active, mirrorSync);
155
+ }
77
156
  if (settingsApi?.replace) {
78
157
  try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
79
158
  }
80
159
  }
81
160
  };
82
161
 
162
+ sshService.setJumpProfileResolver((jumpId) => store.getProfile(jumpId));
163
+
164
+ // If autoSync was enabled on restart, resume watcher for active profile
165
+ if (currentConfig.autoSyncActive && currentConfig.activeProfileId) {
166
+ const active = store.getActiveProfile();
167
+ if (active) watcherService.start(active, mirrorSync);
168
+ }
169
+
83
170
  // Register settings namespace if settings service is available
84
171
  ctx.inject(['settings'], (sctx) => {
85
172
  const settingsService = sctx.get ? (sctx.get('settings') ?? sctx.settings) : sctx.settings;
@@ -91,6 +178,7 @@ export function apply(ctx, config = {}) {
91
178
  if (snap) {
92
179
  if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
93
180
  if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
181
+ if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
94
182
  }
95
183
  sctx.effect(() => () => {
96
184
  settingsApi = null;
@@ -101,20 +189,38 @@ export function apply(ctx, config = {}) {
101
189
  ctx.provide('remoteFs');
102
190
  ctx.provide('remoteSync');
103
191
  ctx.provide('remoteTunnel');
192
+ ctx.provide('remoteVault');
193
+ ctx.provide('remoteDocker');
194
+ ctx.provide('remoteHealth');
195
+ ctx.provide('remoteWatcher');
104
196
 
105
197
  ctx.remoteSsh = sshService;
106
198
  ctx.remoteFs = remoteFs;
107
199
  ctx.remoteSync = mirrorSync;
108
200
  ctx.remoteTunnel = tunnelService;
201
+ ctx.remoteVault = vaultService;
202
+ ctx.remoteDocker = dockerService;
203
+ ctx.remoteHealth = healthService;
204
+ ctx.remoteWatcher = watcherService;
109
205
 
110
206
  // Tools registration
111
- registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, () => store.getActiveProfile());
207
+ registerRemoteTools(
208
+ ctx,
209
+ sshService,
210
+ remoteFs,
211
+ mirrorSync,
212
+ tunnelService,
213
+ () => store.getActiveProfile(),
214
+ (id) => store.getProfile(id),
215
+ dockerService
216
+ );
112
217
 
113
218
  // API Routes
114
- registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store);
219
+ registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store, dockerService, healthService, watcherService);
115
220
 
116
221
  // Cleanup on unload
117
222
  ctx.on('dispose', () => {
223
+ watcherService.stopAll();
118
224
  tunnelService.stopAll();
119
225
  sshService.disconnectAll();
120
226
  });
@@ -41,7 +41,8 @@ export class MirrorSyncService {
41
41
  }
42
42
 
43
43
  /**
44
- * 3-way conflict aware pull: Remote -> Local Mirror
44
+ * Fast Delta-based 3-way conflict aware pull: Remote -> Local Mirror
45
+ * Uses file size and modification time to avoid downloading unchanged files.
45
46
  */
46
47
  async pull(profile, remoteDir, localDir, dryRun = false) {
47
48
  fs.mkdirSync(localDir, { recursive: true });
@@ -57,8 +58,8 @@ export class MirrorSyncService {
57
58
  const updated = [];
58
59
  const newSnapshot = { ...lastSnapshot };
59
60
 
60
- // Try GNU find with printf first; fallback to standard find if unsupported
61
- let findCmd = `find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*' -printf '%P\\t%s\\t%T@\\n' 2>/dev/null || find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*'`;
61
+ // Efficient remote scan with filename, size, and mtime
62
+ const findCmd = `find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*' -printf '%P\\t%s\\t%T@\\n' 2>/dev/null || find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*'`;
62
63
  const execRes = await this.sshService.exec(profile, findCmd, remoteDir);
63
64
  if (execRes.code !== 0 && !execRes.stdout.trim()) {
64
65
  throw new Error(`Failed to scan remote dir: ${execRes.stderr || 'No response from remote host'}`);
@@ -66,13 +67,44 @@ export class MirrorSyncService {
66
67
 
67
68
  const lines = execRes.stdout.split('\n').filter(Boolean);
68
69
  for (const line of lines) {
69
- let relPath = line.split('\t')[0].trim();
70
+ const parts = line.split('\t');
71
+ let relPath = parts[0].trim();
70
72
  if (relPath.startsWith('./')) relPath = relPath.slice(2);
71
73
  if (!relPath || this.isIgnored(relPath)) continue;
72
74
 
75
+ const remoteSize = parts.length > 1 ? parseInt(parts[1], 10) : null;
76
+ const remoteMtime = parts.length > 2 ? parseFloat(parts[2]) : null;
77
+
73
78
  const remoteFullPath = path.posix.join(remoteDir, relPath);
74
79
  const localFullPath = path.join(localDir, relPath);
75
80
 
81
+ const baseEntry = lastSnapshot[relPath];
82
+ const baseHash = typeof baseEntry === 'object' ? baseEntry?.hash : baseEntry;
83
+ const baseRemoteMtime = typeof baseEntry === 'object' ? baseEntry?.remoteMtime : null;
84
+ const baseRemoteSize = typeof baseEntry === 'object' ? baseEntry?.remoteSize : null;
85
+
86
+ let localExists = fs.existsSync(localFullPath);
87
+ let localStat = localExists ? fs.statSync(localFullPath) : null;
88
+
89
+ // Delta Fast Path: if remote size & mtime match previous snapshot and local is unmodified
90
+ if (
91
+ localExists &&
92
+ baseHash &&
93
+ baseRemoteMtime &&
94
+ remoteMtime &&
95
+ Math.abs(remoteMtime - baseRemoteMtime) < 0.01 &&
96
+ baseRemoteSize === remoteSize
97
+ ) {
98
+ // Check if local was modified
99
+ const localContent = fs.readFileSync(localFullPath, 'utf8');
100
+ const localHash = this.computeHash(localContent);
101
+ if (localHash === baseHash) {
102
+ // File unchanged on both ends
103
+ continue;
104
+ }
105
+ }
106
+
107
+ // Download content only when potentially changed
76
108
  let remoteContent = '';
77
109
  try {
78
110
  remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
@@ -80,11 +112,10 @@ export class MirrorSyncService {
80
112
  continue; // skip unreadable files
81
113
  }
82
114
  const remoteHash = this.computeHash(remoteContent);
83
- const baseHash = lastSnapshot[relPath];
84
115
 
85
116
  let localContent = null;
86
117
  let localHash = null;
87
- if (fs.existsSync(localFullPath)) {
118
+ if (localExists) {
88
119
  localContent = fs.readFileSync(localFullPath, 'utf8');
89
120
  localHash = this.computeHash(localContent);
90
121
  }
@@ -100,11 +131,14 @@ export class MirrorSyncService {
100
131
  if (!dryRun) {
101
132
  fs.mkdirSync(path.dirname(localFullPath), { recursive: true });
102
133
  fs.writeFileSync(localFullPath, remoteContent, 'utf8');
103
- newSnapshot[relPath] = remoteHash;
104
134
  }
105
- } else {
106
- newSnapshot[relPath] = remoteHash;
107
135
  }
136
+
137
+ newSnapshot[relPath] = {
138
+ hash: remoteHash,
139
+ remoteSize,
140
+ remoteMtime
141
+ };
108
142
  }
109
143
 
110
144
  if (!dryRun) {
@@ -120,7 +154,7 @@ export class MirrorSyncService {
120
154
  }
121
155
 
122
156
  /**
123
- * 3-way conflict aware push: Local Mirror -> Remote
157
+ * Fast Delta-based 3-way conflict aware push: Local Mirror -> Remote
124
158
  */
125
159
  async push(profile, localDir, remoteDir, force = false, dryRun = false) {
126
160
  if (!fs.existsSync(localDir)) {
@@ -164,22 +198,29 @@ export class MirrorSyncService {
164
198
  for (const rel of localFiles) {
165
199
  const localFull = path.join(localDir, rel);
166
200
  const remoteFull = path.posix.join(remoteDir, rel.replace(/\\/g, '/'));
201
+ const stat = fs.statSync(localFull);
167
202
  const content = fs.readFileSync(localFull, 'utf8');
168
203
  const localHash = this.computeHash(content);
169
- const baseHash = snapshot[rel];
204
+
205
+ const baseEntry = snapshot[rel];
206
+ const baseHash = typeof baseEntry === 'object' ? baseEntry?.hash : baseEntry;
170
207
 
171
208
  if (baseHash && baseHash === localHash && !force) {
172
209
  continue; // Unchanged locally
173
210
  }
174
211
 
175
212
  if (!dryRun) {
176
- // Ensure remote parent dir exists
177
213
  const parent = path.posix.dirname(remoteFull);
178
214
  await this.remoteFs.mkdir(profile, parent);
179
215
  await this.remoteFs.writeFile(profile, remoteFull, content, 'utf8');
180
- snapshot[rel] = localHash;
181
216
  }
182
217
  pushed.push(rel);
218
+
219
+ snapshot[rel] = {
220
+ hash: localHash,
221
+ localSize: stat.size,
222
+ localMtime: stat.mtimeMs
223
+ };
183
224
  }
184
225
 
185
226
  if (!dryRun) {
@@ -6,12 +6,42 @@ export class RemoteFsService {
6
6
  */
7
7
  constructor(sshService) {
8
8
  this.sshService = sshService;
9
+ /** @type {Map<string, { data: any, expiresAt: number }>} */
10
+ this.cache = new Map();
11
+ this.cacheTtlMs = 15000; // 15 seconds
12
+ }
13
+
14
+ cacheKey(profileId, action, remotePath) {
15
+ return `${profileId}:${action}:${remotePath}`;
16
+ }
17
+
18
+ invalidateCache(profileId, subPath = null) {
19
+ if (!profileId) {
20
+ this.cache.clear();
21
+ return;
22
+ }
23
+ const prefix = subPath ? `${profileId}:` : `${profileId}:`;
24
+ for (const key of this.cache.keys()) {
25
+ if (key.startsWith(prefix)) {
26
+ if (!subPath || key.includes(subPath)) {
27
+ this.cache.delete(key);
28
+ }
29
+ }
30
+ }
9
31
  }
10
32
 
11
33
  /**
12
- * Read directory entries
34
+ * Read directory entries with in-memory TTL cache
13
35
  */
14
- async listDir(profile, remotePath) {
36
+ async listDir(profile, remotePath, bypassCache = false) {
37
+ const key = this.cacheKey(profile.id, 'list', remotePath);
38
+ if (!bypassCache) {
39
+ const cached = this.cache.get(key);
40
+ if (cached && cached.expiresAt > Date.now()) {
41
+ return cached.data;
42
+ }
43
+ }
44
+
15
45
  const sftp = await this.sshService.getSftp(profile);
16
46
  return new Promise((resolve, reject) => {
17
47
  sftp.readdir(remotePath, (err, list) => {
@@ -24,25 +54,36 @@ export class RemoteFsService {
24
54
  mtime: item.attrs.mtime,
25
55
  permissions: (item.attrs.mode & 0o777).toString(8)
26
56
  }));
57
+ this.cache.set(key, { data: entries, expiresAt: Date.now() + this.cacheTtlMs });
27
58
  resolve(entries);
28
59
  });
29
60
  });
30
61
  }
31
62
 
32
63
  /**
33
- * Stat remote path
64
+ * Stat remote path with cache
34
65
  */
35
- async stat(profile, remotePath) {
66
+ async stat(profile, remotePath, bypassCache = false) {
67
+ const key = this.cacheKey(profile.id, 'stat', remotePath);
68
+ if (!bypassCache) {
69
+ const cached = this.cache.get(key);
70
+ if (cached && cached.expiresAt > Date.now()) {
71
+ return cached.data;
72
+ }
73
+ }
74
+
36
75
  const sftp = await this.sshService.getSftp(profile);
37
76
  return new Promise((resolve, reject) => {
38
77
  sftp.stat(remotePath, (err, stats) => {
39
78
  if (err) return reject(err);
40
- resolve({
79
+ const res = {
41
80
  isDirectory: (stats.mode & 0o40000) !== 0,
42
81
  isFile: (stats.mode & 0o100000) !== 0,
43
82
  size: stats.size,
44
83
  mtime: stats.mtime
45
- });
84
+ };
85
+ this.cache.set(key, { data: res, expiresAt: Date.now() + this.cacheTtlMs });
86
+ resolve(res);
46
87
  });
47
88
  });
48
89
  }
@@ -64,6 +105,14 @@ export class RemoteFsService {
64
105
  });
65
106
  }
66
107
 
108
+ /**
109
+ * Get raw SFTP readable stream
110
+ */
111
+ async createReadStream(profile, remotePath) {
112
+ const sftp = await this.sshService.getSftp(profile);
113
+ return sftp.createReadStream(remotePath);
114
+ }
115
+
67
116
  /**
68
117
  * Atomic file write using temp file and rename
69
118
  */
@@ -82,10 +131,10 @@ export class RemoteFsService {
82
131
  return new Promise((resolve, reject) => {
83
132
  sftp.rename(tempPath, remotePath, (err) => {
84
133
  if (err) {
85
- // If rename fails, try unlink temp
86
134
  sftp.unlink(tempPath, () => {});
87
135
  return reject(err);
88
136
  }
137
+ this.invalidateCache(profile.id, path.posix.dirname(remotePath));
89
138
  resolve({ success: true, bytes: data.length });
90
139
  });
91
140
  });
@@ -100,6 +149,7 @@ export class RemoteFsService {
100
149
  if (res.code !== 0) {
101
150
  throw new Error(`Failed to create remote dir ${remotePath}: ${res.stderr}`);
102
151
  }
152
+ this.invalidateCache(profile.id);
103
153
  return { success: true };
104
154
  }
105
155
 
@@ -112,6 +162,7 @@ export class RemoteFsService {
112
162
  if (res.code !== 0) {
113
163
  throw new Error(`Failed to remove remote path ${remotePath}: ${res.stderr}`);
114
164
  }
165
+ this.invalidateCache(profile.id);
115
166
  return { success: true };
116
167
  }
117
168
  }