@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.
- package/lib/client.js +1991 -1278
- package/lib/docker-service.js +93 -0
- package/lib/health-service.js +117 -0
- package/lib/index.js +116 -10
- package/lib/mirror-sync-service.js +54 -13
- package/lib/remote-fs-service.js +58 -7
- package/lib/routes.js +339 -46
- package/lib/ssh-service.js +207 -20
- package/lib/tools.js +124 -12
- package/lib/tunnel-service.js +79 -36
- package/lib/vault-service.js +184 -0
- package/lib/watcher-service.js +111 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
48
|
-
|
|
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() {
|
|
51
|
-
|
|
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(
|
|
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
|
-
//
|
|
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
|
}
|