@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/docs/design/DESIGN.md +60 -51
- package/docs/plans/findings.md +14 -2
- package/docs/plans/progress.md +11 -10
- package/docs/plans/task_plan.md +16 -19
- package/lib/client.js +1766 -966
- package/lib/index.js +79 -18
- package/lib/mirror-sync-service.js +73 -16
- package/lib/remote-fs-service.js +58 -7
- package/lib/routes.js +291 -57
- package/lib/ssh-service.js +188 -17
- package/lib/tools.js +25 -11
- package/lib/tunnel-service.js +85 -32
- 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';
|
|
@@ -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
|
-
|
|
43
|
-
|
|
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() {
|
|
46
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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) {
|
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
|
}
|