@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/client.js +1766 -1278
- package/lib/index.js +61 -5
- package/lib/mirror-sync-service.js +54 -13
- package/lib/remote-fs-service.js +58 -7
- package/lib/routes.js +215 -46
- package/lib/ssh-service.js +158 -10
- package/lib/tools.js +25 -11
- package/lib/tunnel-service.js +79 -36
- package/lib/vault-service.js +184 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
48
|
-
|
|
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() {
|
|
51
|
-
|
|
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
|
-
//
|
|
61
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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) {
|
package/lib/remote-fs-service.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|