@goodandready/dsh-remote-workspace 0.1.1 → 0.2.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.
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
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';
@@ -38,21 +39,73 @@ export function apply(ctx, config = {}) {
38
39
  activeProfileId: cfg.activeProfileId || null
39
40
  };
40
41
 
41
- const sshService = new SshService(ctx);
42
+ const vaultService = new VaultService();
43
+ const sshService = new SshService(ctx, vaultService);
42
44
  const remoteFs = new RemoteFsService(sshService);
43
45
  const mirrorSync = new MirrorSyncService(remoteFs, sshService);
44
46
  const tunnelService = new TunnelService(sshService);
45
47
 
48
+ // Migrate any existing plain passwords into vault upon start
49
+ for (const p of currentConfig.profiles) {
50
+ if (p.id && (p.password || p.privateKey || p.passphrase)) {
51
+ if (p.password && p.password !== '••••••••') {
52
+ vaultService.setProfileSecrets(p.id, { password: p.password });
53
+ p.password = '';
54
+ }
55
+ if (p.privateKey && p.privateKey !== '••••••••') {
56
+ vaultService.setProfileSecrets(p.id, { privateKey: p.privateKey });
57
+ p.privateKey = '';
58
+ }
59
+ if (p.passphrase && p.passphrase !== '••••••••') {
60
+ vaultService.setProfileSecrets(p.id, { passphrase: p.passphrase });
61
+ p.passphrase = '';
62
+ }
63
+ }
64
+ }
65
+
46
66
  const store = {
47
- getProfiles() { return currentConfig.profiles || []; },
48
- getProfile(id) { return (currentConfig.profiles || []).find((p) => p.id === id); },
67
+ vault: vaultService,
68
+ getRawProfiles() { return currentConfig.profiles || []; },
69
+ getProfiles() {
70
+ return (currentConfig.profiles || []).map((p) => vaultService.sanitizeProfile(p));
71
+ },
72
+ getProfile(id) {
73
+ const p = (currentConfig.profiles || []).find((x) => x.id === id);
74
+ return p ? vaultService.hydrateProfile(p) : undefined;
75
+ },
49
76
  getActiveId() { return currentConfig.activeProfileId; },
50
- getActiveProfile() { return (currentConfig.profiles || []).find((p) => p.id === currentConfig.activeProfileId); },
51
- async saveProfile(p) {
77
+ getActiveProfile() {
78
+ const p = (currentConfig.profiles || []).find((x) => x.id === currentConfig.activeProfileId);
79
+ return p ? vaultService.hydrateProfile(p) : undefined;
80
+ },
81
+ async saveProfile(rawP) {
52
82
  const list = [...(currentConfig.profiles || [])];
83
+ const p = { ...rawP };
84
+
85
+ // Save secret credentials into isolated .env file if provided
86
+ const secretsToUpdate = {};
87
+ if (p.password !== undefined && p.password !== '••••••••') {
88
+ secretsToUpdate.password = p.password;
89
+ }
90
+ if (p.privateKey !== undefined && p.privateKey !== '••••••••') {
91
+ secretsToUpdate.privateKey = p.privateKey;
92
+ }
93
+ if (p.passphrase !== undefined && p.passphrase !== '••••••••') {
94
+ secretsToUpdate.passphrase = p.passphrase;
95
+ }
96
+ if (Object.keys(secretsToUpdate).length > 0) {
97
+ vaultService.setProfileSecrets(p.id, secretsToUpdate);
98
+ }
99
+
100
+ // Do NOT keep raw secrets in public config
101
+ p.password = '';
102
+ p.privateKey = '';
103
+ p.passphrase = '';
104
+
53
105
  const idx = list.findIndex((x) => x.id === p.id);
54
106
  if (idx >= 0) list[idx] = { ...list[idx], ...p };
55
107
  else list.push(p);
108
+
56
109
  currentConfig.profiles = list;
57
110
  if (!currentConfig.activeProfileId && list.length === 1) {
58
111
  currentConfig.activeProfileId = p.id;
@@ -67,6 +120,7 @@ export function apply(ctx, config = {}) {
67
120
  if (currentConfig.activeProfileId === id) {
68
121
  currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
69
122
  }
123
+ vaultService.deleteProfileSecrets(id);
70
124
  sshService.disconnect(id);
71
125
  if (settingsApi?.replace) {
72
126
  try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
@@ -101,11 +155,13 @@ export function apply(ctx, config = {}) {
101
155
  ctx.provide('remoteFs');
102
156
  ctx.provide('remoteSync');
103
157
  ctx.provide('remoteTunnel');
158
+ ctx.provide('remoteVault');
104
159
 
105
160
  ctx.remoteSsh = sshService;
106
161
  ctx.remoteFs = remoteFs;
107
162
  ctx.remoteSync = mirrorSync;
108
163
  ctx.remoteTunnel = tunnelService;
164
+ ctx.remoteVault = vaultService;
109
165
 
110
166
  // Tools registration
111
167
  registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, () => store.getActiveProfile());
@@ -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
  }