@goodandready/dsh-remote-workspace 0.1.0 → 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';
@@ -14,11 +15,11 @@ export const inject = ['tools'];
14
15
 
15
16
  export const Config = z.object({
16
17
  profiles: z.array(z.object({
17
- id: z.string(),
18
- name: z.string(),
19
- host: z.string(),
18
+ id: z.string().default(''),
19
+ name: z.string().default(''),
20
+ host: z.string().default(''),
20
21
  port: z.number().default(22),
21
- username: z.string(),
22
+ username: z.string().default('root'),
22
23
  authType: z.string().default('key'),
23
24
  privateKeyPath: z.string().default(''),
24
25
  privateKey: z.string().default(''),
@@ -38,22 +39,80 @@ export function apply(ctx, config = {}) {
38
39
  activeProfileId: cfg.activeProfileId || null
39
40
  };
40
41
 
42
+ const vaultService = new VaultService();
43
+ const sshService = new SshService(ctx, vaultService);
44
+ const remoteFs = new RemoteFsService(sshService);
45
+ const mirrorSync = new MirrorSyncService(remoteFs, sshService);
46
+ const tunnelService = new TunnelService(sshService);
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
+
41
66
  const store = {
42
- getProfiles() { return currentConfig.profiles || []; },
43
- 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
+ },
44
76
  getActiveId() { return currentConfig.activeProfileId; },
45
- getActiveProfile() { return (currentConfig.profiles || []).find((p) => p.id === currentConfig.activeProfileId); },
46
- 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) {
47
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
+
48
105
  const idx = list.findIndex((x) => x.id === p.id);
49
106
  if (idx >= 0) list[idx] = { ...list[idx], ...p };
50
107
  else list.push(p);
108
+
51
109
  currentConfig.profiles = list;
52
110
  if (!currentConfig.activeProfileId && list.length === 1) {
53
111
  currentConfig.activeProfileId = p.id;
54
112
  }
113
+ sshService.invalidate(p.id);
55
114
  if (settingsApi?.replace) {
56
- await settingsApi.replace(Config(currentConfig));
115
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
57
116
  }
58
117
  },
59
118
  async deleteProfile(id) {
@@ -61,23 +120,28 @@ export function apply(ctx, config = {}) {
61
120
  if (currentConfig.activeProfileId === id) {
62
121
  currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
63
122
  }
123
+ vaultService.deleteProfileSecrets(id);
124
+ sshService.disconnect(id);
64
125
  if (settingsApi?.replace) {
65
- await settingsApi.replace(Config(currentConfig));
126
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
66
127
  }
67
128
  },
68
129
  async setActiveId(id) {
69
130
  currentConfig.activeProfileId = id;
70
131
  if (settingsApi?.replace) {
71
- await settingsApi.replace(Config(currentConfig));
132
+ try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
72
133
  }
73
134
  }
74
135
  };
75
136
 
76
137
  // Register settings namespace if settings service is available
77
138
  ctx.inject(['settings'], (sctx) => {
78
- const scope = sctx.settings.register(NS, Config, { base: cfg });
139
+ const settingsService = sctx.get ? (sctx.get('settings') ?? sctx.settings) : sctx.settings;
140
+ if (!settingsService || typeof settingsService.register !== 'function') return;
141
+
142
+ const scope = settingsService.register(NS, Config, { base: cfg });
79
143
  settingsApi = scope;
80
- const snap = scope.get();
144
+ const snap = scope.get ? scope.get() : null;
81
145
  if (snap) {
82
146
  if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
83
147
  if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
@@ -87,20 +151,17 @@ export function apply(ctx, config = {}) {
87
151
  });
88
152
  });
89
153
 
90
- const sshService = new SshService(ctx);
91
- const remoteFs = new RemoteFsService(sshService);
92
- const mirrorSync = new MirrorSyncService(remoteFs, sshService);
93
- const tunnelService = new TunnelService(sshService);
94
-
95
154
  ctx.provide('remoteSsh');
96
155
  ctx.provide('remoteFs');
97
156
  ctx.provide('remoteSync');
98
157
  ctx.provide('remoteTunnel');
158
+ ctx.provide('remoteVault');
99
159
 
100
160
  ctx.remoteSsh = sshService;
101
161
  ctx.remoteFs = remoteFs;
102
162
  ctx.remoteSync = mirrorSync;
103
163
  ctx.remoteTunnel = tunnelService;
164
+ ctx.remoteVault = vaultService;
104
165
 
105
166
  // Tools registration
106
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,28 +58,64 @@ export class MirrorSyncService {
57
58
  const updated = [];
58
59
  const newSnapshot = { ...lastSnapshot };
59
60
 
60
- // Find remote files using find over ssh for speed
61
- const findCmd = `find . -maxdepth 5 -type f ! -path '*/.*' ! -path '*/node_modules/*' -printf '%P\\t%s\\t%T@\\n'`;
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
- if (execRes.code !== 0) {
64
- throw new Error(`Failed to scan remote dir: ${execRes.stderr}`);
64
+ if (execRes.code !== 0 && !execRes.stdout.trim()) {
65
+ throw new Error(`Failed to scan remote dir: ${execRes.stderr || 'No response from remote host'}`);
65
66
  }
66
67
 
67
68
  const lines = execRes.stdout.split('\n').filter(Boolean);
68
69
  for (const line of lines) {
69
- const [relPath] = line.split('\t');
70
+ const parts = line.split('\t');
71
+ let relPath = parts[0].trim();
72
+ if (relPath.startsWith('./')) relPath = relPath.slice(2);
70
73
  if (!relPath || this.isIgnored(relPath)) continue;
71
74
 
75
+ const remoteSize = parts.length > 1 ? parseInt(parts[1], 10) : null;
76
+ const remoteMtime = parts.length > 2 ? parseFloat(parts[2]) : null;
77
+
72
78
  const remoteFullPath = path.posix.join(remoteDir, relPath);
73
79
  const localFullPath = path.join(localDir, relPath);
74
80
 
75
- const remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
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
108
+ let remoteContent = '';
109
+ try {
110
+ remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
111
+ } catch (err) {
112
+ continue; // skip unreadable files
113
+ }
76
114
  const remoteHash = this.computeHash(remoteContent);
77
- const baseHash = lastSnapshot[relPath];
78
115
 
79
116
  let localContent = null;
80
117
  let localHash = null;
81
- if (fs.existsSync(localFullPath)) {
118
+ if (localExists) {
82
119
  localContent = fs.readFileSync(localFullPath, 'utf8');
83
120
  localHash = this.computeHash(localContent);
84
121
  }
@@ -94,11 +131,14 @@ export class MirrorSyncService {
94
131
  if (!dryRun) {
95
132
  fs.mkdirSync(path.dirname(localFullPath), { recursive: true });
96
133
  fs.writeFileSync(localFullPath, remoteContent, 'utf8');
97
- newSnapshot[relPath] = remoteHash;
98
134
  }
99
- } else {
100
- newSnapshot[relPath] = remoteHash;
101
135
  }
136
+
137
+ newSnapshot[relPath] = {
138
+ hash: remoteHash,
139
+ remoteSize,
140
+ remoteMtime
141
+ };
102
142
  }
103
143
 
104
144
  if (!dryRun) {
@@ -114,9 +154,19 @@ export class MirrorSyncService {
114
154
  }
115
155
 
116
156
  /**
117
- * 3-way conflict aware push: Local Mirror -> Remote
157
+ * Fast Delta-based 3-way conflict aware push: Local Mirror -> Remote
118
158
  */
119
159
  async push(profile, localDir, remoteDir, force = false, dryRun = false) {
160
+ if (!fs.existsSync(localDir)) {
161
+ fs.mkdirSync(localDir, { recursive: true });
162
+ return {
163
+ success: true,
164
+ pushed: [],
165
+ conflicts: [],
166
+ dryRun
167
+ };
168
+ }
169
+
120
170
  const snapshotFile = path.join(localDir, '.dsh-sync-snapshot.json');
121
171
  let snapshot = {};
122
172
  if (fs.existsSync(snapshotFile)) {
@@ -148,22 +198,29 @@ export class MirrorSyncService {
148
198
  for (const rel of localFiles) {
149
199
  const localFull = path.join(localDir, rel);
150
200
  const remoteFull = path.posix.join(remoteDir, rel.replace(/\\/g, '/'));
201
+ const stat = fs.statSync(localFull);
151
202
  const content = fs.readFileSync(localFull, 'utf8');
152
203
  const localHash = this.computeHash(content);
153
- const baseHash = snapshot[rel];
204
+
205
+ const baseEntry = snapshot[rel];
206
+ const baseHash = typeof baseEntry === 'object' ? baseEntry?.hash : baseEntry;
154
207
 
155
208
  if (baseHash && baseHash === localHash && !force) {
156
209
  continue; // Unchanged locally
157
210
  }
158
211
 
159
212
  if (!dryRun) {
160
- // Ensure remote parent dir exists
161
213
  const parent = path.posix.dirname(remoteFull);
162
214
  await this.remoteFs.mkdir(profile, parent);
163
215
  await this.remoteFs.writeFile(profile, remoteFull, content, 'utf8');
164
- snapshot[rel] = localHash;
165
216
  }
166
217
  pushed.push(rel);
218
+
219
+ snapshot[rel] = {
220
+ hash: localHash,
221
+ localSize: stat.size,
222
+ localMtime: stat.mtimeMs
223
+ };
167
224
  }
168
225
 
169
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
  }