@goodandready/dsh-remote-workspace 0.1.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/LICENSE +21 -0
- package/README.md +238 -0
- package/README.ru.md +238 -0
- package/README.zh.md +238 -0
- package/cordis.patch.yml +3 -0
- package/docs/design/DESIGN.md +62 -0
- package/docs/plans/findings.md +7 -0
- package/docs/plans/progress.md +14 -0
- package/docs/plans/task_plan.md +26 -0
- package/lib/client.js +966 -0
- package/lib/index.js +116 -0
- package/lib/mirror-sync-service.js +180 -0
- package/lib/remote-fs-service.js +117 -0
- package/lib/routes.js +164 -0
- package/lib/ssh-service.js +176 -0
- package/lib/tools.js +138 -0
- package/lib/tunnel-service.js +88 -0
- package/lib/types.js +29 -0
- package/package.json +75 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { SshService } from './ssh-service.js';
|
|
3
|
+
import { RemoteFsService } from './remote-fs-service.js';
|
|
4
|
+
import { MirrorSyncService } from './mirror-sync-service.js';
|
|
5
|
+
import { TunnelService } from './tunnel-service.js';
|
|
6
|
+
import { registerRemoteTools } from './tools.js';
|
|
7
|
+
import { registerApiRoutes } from './routes.js';
|
|
8
|
+
|
|
9
|
+
export const name = '@goodandready/dsh-remote-workspace';
|
|
10
|
+
|
|
11
|
+
const NS = 'dsh-remote-workspace';
|
|
12
|
+
|
|
13
|
+
export const inject = ['tools'];
|
|
14
|
+
|
|
15
|
+
export const Config = z.object({
|
|
16
|
+
profiles: z.array(z.object({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
name: z.string(),
|
|
19
|
+
host: z.string(),
|
|
20
|
+
port: z.number().default(22),
|
|
21
|
+
username: z.string(),
|
|
22
|
+
authType: z.string().default('key'),
|
|
23
|
+
privateKeyPath: z.string().default(''),
|
|
24
|
+
privateKey: z.string().default(''),
|
|
25
|
+
passphrase: z.string().default(''),
|
|
26
|
+
password: z.string().default(''),
|
|
27
|
+
remoteWorkspace: z.string().default(''),
|
|
28
|
+
localMirrorPath: z.string().default('')
|
|
29
|
+
})).default([]).description('List of configured remote machines and workspaces.'),
|
|
30
|
+
activeProfileId: z.string().default('').description('ID of active remote profile.')
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export function apply(ctx, config = {}) {
|
|
34
|
+
const cfg = config || {};
|
|
35
|
+
let settingsApi = null;
|
|
36
|
+
let currentConfig = {
|
|
37
|
+
profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
|
|
38
|
+
activeProfileId: cfg.activeProfileId || null
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const store = {
|
|
42
|
+
getProfiles() { return currentConfig.profiles || []; },
|
|
43
|
+
getProfile(id) { return (currentConfig.profiles || []).find((p) => p.id === id); },
|
|
44
|
+
getActiveId() { return currentConfig.activeProfileId; },
|
|
45
|
+
getActiveProfile() { return (currentConfig.profiles || []).find((p) => p.id === currentConfig.activeProfileId); },
|
|
46
|
+
async saveProfile(p) {
|
|
47
|
+
const list = [...(currentConfig.profiles || [])];
|
|
48
|
+
const idx = list.findIndex((x) => x.id === p.id);
|
|
49
|
+
if (idx >= 0) list[idx] = { ...list[idx], ...p };
|
|
50
|
+
else list.push(p);
|
|
51
|
+
currentConfig.profiles = list;
|
|
52
|
+
if (!currentConfig.activeProfileId && list.length === 1) {
|
|
53
|
+
currentConfig.activeProfileId = p.id;
|
|
54
|
+
}
|
|
55
|
+
if (settingsApi?.replace) {
|
|
56
|
+
await settingsApi.replace(Config(currentConfig));
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
async deleteProfile(id) {
|
|
60
|
+
currentConfig.profiles = (currentConfig.profiles || []).filter((p) => p.id !== id);
|
|
61
|
+
if (currentConfig.activeProfileId === id) {
|
|
62
|
+
currentConfig.activeProfileId = currentConfig.profiles[0]?.id || null;
|
|
63
|
+
}
|
|
64
|
+
if (settingsApi?.replace) {
|
|
65
|
+
await settingsApi.replace(Config(currentConfig));
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
async setActiveId(id) {
|
|
69
|
+
currentConfig.activeProfileId = id;
|
|
70
|
+
if (settingsApi?.replace) {
|
|
71
|
+
await settingsApi.replace(Config(currentConfig));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Register settings namespace if settings service is available
|
|
77
|
+
ctx.inject(['settings'], (sctx) => {
|
|
78
|
+
const scope = sctx.settings.register(NS, Config, { base: cfg });
|
|
79
|
+
settingsApi = scope;
|
|
80
|
+
const snap = scope.get();
|
|
81
|
+
if (snap) {
|
|
82
|
+
if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
|
|
83
|
+
if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
|
|
84
|
+
}
|
|
85
|
+
sctx.effect(() => () => {
|
|
86
|
+
settingsApi = null;
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
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
|
+
ctx.provide('remoteSsh');
|
|
96
|
+
ctx.provide('remoteFs');
|
|
97
|
+
ctx.provide('remoteSync');
|
|
98
|
+
ctx.provide('remoteTunnel');
|
|
99
|
+
|
|
100
|
+
ctx.remoteSsh = sshService;
|
|
101
|
+
ctx.remoteFs = remoteFs;
|
|
102
|
+
ctx.remoteSync = mirrorSync;
|
|
103
|
+
ctx.remoteTunnel = tunnelService;
|
|
104
|
+
|
|
105
|
+
// Tools registration
|
|
106
|
+
registerRemoteTools(ctx, sshService, remoteFs, mirrorSync, tunnelService, () => store.getActiveProfile());
|
|
107
|
+
|
|
108
|
+
// API Routes
|
|
109
|
+
registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store);
|
|
110
|
+
|
|
111
|
+
// Cleanup on unload
|
|
112
|
+
ctx.on('dispose', () => {
|
|
113
|
+
tunnelService.stopAll();
|
|
114
|
+
sshService.disconnectAll();
|
|
115
|
+
});
|
|
116
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export class MirrorSyncService {
|
|
6
|
+
/**
|
|
7
|
+
* @param {import('./remote-fs-service.js').RemoteFsService} remoteFs
|
|
8
|
+
* @param {import('./ssh-service.js').SshService} sshService
|
|
9
|
+
*/
|
|
10
|
+
constructor(remoteFs, sshService) {
|
|
11
|
+
this.remoteFs = remoteFs;
|
|
12
|
+
this.sshService = sshService;
|
|
13
|
+
this.defaultIgnore = [
|
|
14
|
+
'.git',
|
|
15
|
+
'node_modules',
|
|
16
|
+
'.worktrees',
|
|
17
|
+
'dist',
|
|
18
|
+
'build',
|
|
19
|
+
'.next',
|
|
20
|
+
'.cache',
|
|
21
|
+
'*.pyc',
|
|
22
|
+
'__pycache__'
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
computeHash(content) {
|
|
27
|
+
return crypto.createHash('sha256').update(content).digest('hex');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
isIgnored(relPath) {
|
|
31
|
+
const parts = relPath.split(/[\\/]/);
|
|
32
|
+
for (const pattern of this.defaultIgnore) {
|
|
33
|
+
if (pattern.startsWith('*.')) {
|
|
34
|
+
const ext = pattern.slice(1);
|
|
35
|
+
if (relPath.endsWith(ext)) return true;
|
|
36
|
+
} else {
|
|
37
|
+
if (parts.includes(pattern)) return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 3-way conflict aware pull: Remote -> Local Mirror
|
|
45
|
+
*/
|
|
46
|
+
async pull(profile, remoteDir, localDir, dryRun = false) {
|
|
47
|
+
fs.mkdirSync(localDir, { recursive: true });
|
|
48
|
+
const snapshotFile = path.join(localDir, '.dsh-sync-snapshot.json');
|
|
49
|
+
let lastSnapshot = {};
|
|
50
|
+
if (fs.existsSync(snapshotFile)) {
|
|
51
|
+
try {
|
|
52
|
+
lastSnapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
|
|
53
|
+
} catch (_) {}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const conflicts = [];
|
|
57
|
+
const updated = [];
|
|
58
|
+
const newSnapshot = { ...lastSnapshot };
|
|
59
|
+
|
|
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'`;
|
|
62
|
+
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}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const lines = execRes.stdout.split('\n').filter(Boolean);
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const [relPath] = line.split('\t');
|
|
70
|
+
if (!relPath || this.isIgnored(relPath)) continue;
|
|
71
|
+
|
|
72
|
+
const remoteFullPath = path.posix.join(remoteDir, relPath);
|
|
73
|
+
const localFullPath = path.join(localDir, relPath);
|
|
74
|
+
|
|
75
|
+
const remoteContent = await this.remoteFs.readFile(profile, remoteFullPath, 'utf8');
|
|
76
|
+
const remoteHash = this.computeHash(remoteContent);
|
|
77
|
+
const baseHash = lastSnapshot[relPath];
|
|
78
|
+
|
|
79
|
+
let localContent = null;
|
|
80
|
+
let localHash = null;
|
|
81
|
+
if (fs.existsSync(localFullPath)) {
|
|
82
|
+
localContent = fs.readFileSync(localFullPath, 'utf8');
|
|
83
|
+
localHash = this.computeHash(localContent);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Check conflict: both changed compared to last baseline
|
|
87
|
+
if (baseHash && localHash && remoteHash !== baseHash && localHash !== baseHash && remoteHash !== localHash) {
|
|
88
|
+
conflicts.push({ path: relPath, reason: 'Modified on both remote and local' });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (localHash !== remoteHash) {
|
|
93
|
+
updated.push(relPath);
|
|
94
|
+
if (!dryRun) {
|
|
95
|
+
fs.mkdirSync(path.dirname(localFullPath), { recursive: true });
|
|
96
|
+
fs.writeFileSync(localFullPath, remoteContent, 'utf8');
|
|
97
|
+
newSnapshot[relPath] = remoteHash;
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
newSnapshot[relPath] = remoteHash;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!dryRun) {
|
|
105
|
+
fs.writeFileSync(snapshotFile, JSON.stringify(newSnapshot, null, 2), 'utf8');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
success: conflicts.length === 0,
|
|
110
|
+
pulled: updated,
|
|
111
|
+
conflicts,
|
|
112
|
+
dryRun
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 3-way conflict aware push: Local Mirror -> Remote
|
|
118
|
+
*/
|
|
119
|
+
async push(profile, localDir, remoteDir, force = false, dryRun = false) {
|
|
120
|
+
const snapshotFile = path.join(localDir, '.dsh-sync-snapshot.json');
|
|
121
|
+
let snapshot = {};
|
|
122
|
+
if (fs.existsSync(snapshotFile)) {
|
|
123
|
+
try {
|
|
124
|
+
snapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
|
|
125
|
+
} catch (_) {}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pushed = [];
|
|
129
|
+
const conflicts = [];
|
|
130
|
+
|
|
131
|
+
const walkLocal = (dir, rootDir) => {
|
|
132
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
133
|
+
let files = [];
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
const full = path.join(dir, entry.name);
|
|
136
|
+
const rel = path.relative(rootDir, full);
|
|
137
|
+
if (this.isIgnored(rel)) continue;
|
|
138
|
+
if (entry.isDirectory()) {
|
|
139
|
+
files = files.concat(walkLocal(full, rootDir));
|
|
140
|
+
} else if (entry.isFile()) {
|
|
141
|
+
files.push(rel);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return files;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const localFiles = walkLocal(localDir, localDir);
|
|
148
|
+
for (const rel of localFiles) {
|
|
149
|
+
const localFull = path.join(localDir, rel);
|
|
150
|
+
const remoteFull = path.posix.join(remoteDir, rel.replace(/\\/g, '/'));
|
|
151
|
+
const content = fs.readFileSync(localFull, 'utf8');
|
|
152
|
+
const localHash = this.computeHash(content);
|
|
153
|
+
const baseHash = snapshot[rel];
|
|
154
|
+
|
|
155
|
+
if (baseHash && baseHash === localHash && !force) {
|
|
156
|
+
continue; // Unchanged locally
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!dryRun) {
|
|
160
|
+
// Ensure remote parent dir exists
|
|
161
|
+
const parent = path.posix.dirname(remoteFull);
|
|
162
|
+
await this.remoteFs.mkdir(profile, parent);
|
|
163
|
+
await this.remoteFs.writeFile(profile, remoteFull, content, 'utf8');
|
|
164
|
+
snapshot[rel] = localHash;
|
|
165
|
+
}
|
|
166
|
+
pushed.push(rel);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!dryRun) {
|
|
170
|
+
fs.writeFileSync(snapshotFile, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
success: true,
|
|
175
|
+
pushed,
|
|
176
|
+
conflicts,
|
|
177
|
+
dryRun
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
export class RemoteFsService {
|
|
4
|
+
/**
|
|
5
|
+
* @param {import('./ssh-service.js').SshService} sshService
|
|
6
|
+
*/
|
|
7
|
+
constructor(sshService) {
|
|
8
|
+
this.sshService = sshService;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read directory entries
|
|
13
|
+
*/
|
|
14
|
+
async listDir(profile, remotePath) {
|
|
15
|
+
const sftp = await this.sshService.getSftp(profile);
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
18
|
+
if (err) return reject(err);
|
|
19
|
+
const entries = list.map((item) => ({
|
|
20
|
+
filename: item.filename,
|
|
21
|
+
isDirectory: (item.attrs.mode & 0o40000) !== 0,
|
|
22
|
+
isFile: (item.attrs.mode & 0o100000) !== 0,
|
|
23
|
+
size: item.attrs.size,
|
|
24
|
+
mtime: item.attrs.mtime,
|
|
25
|
+
permissions: (item.attrs.mode & 0o777).toString(8)
|
|
26
|
+
}));
|
|
27
|
+
resolve(entries);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Stat remote path
|
|
34
|
+
*/
|
|
35
|
+
async stat(profile, remotePath) {
|
|
36
|
+
const sftp = await this.sshService.getSftp(profile);
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
sftp.stat(remotePath, (err, stats) => {
|
|
39
|
+
if (err) return reject(err);
|
|
40
|
+
resolve({
|
|
41
|
+
isDirectory: (stats.mode & 0o40000) !== 0,
|
|
42
|
+
isFile: (stats.mode & 0o100000) !== 0,
|
|
43
|
+
size: stats.size,
|
|
44
|
+
mtime: stats.mtime
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read file content with buffer/string conversion
|
|
52
|
+
*/
|
|
53
|
+
async readFile(profile, remotePath, encoding = 'utf8') {
|
|
54
|
+
const sftp = await this.sshService.getSftp(profile);
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
const stream = sftp.createReadStream(remotePath);
|
|
58
|
+
stream.on('data', (chunk) => chunks.push(chunk));
|
|
59
|
+
stream.on('end', () => {
|
|
60
|
+
const buf = Buffer.concat(chunks);
|
|
61
|
+
resolve(encoding ? buf.toString(encoding) : buf);
|
|
62
|
+
});
|
|
63
|
+
stream.on('error', (err) => reject(err));
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Atomic file write using temp file and rename
|
|
69
|
+
*/
|
|
70
|
+
async writeFile(profile, remotePath, content, encoding = 'utf8') {
|
|
71
|
+
const sftp = await this.sshService.getSftp(profile);
|
|
72
|
+
const tempPath = `${remotePath}.dsh-tmp.${Date.now()}`;
|
|
73
|
+
const data = Buffer.isBuffer(content) ? content : Buffer.from(content, encoding);
|
|
74
|
+
|
|
75
|
+
await new Promise((resolve, reject) => {
|
|
76
|
+
const stream = sftp.createWriteStream(tempPath);
|
|
77
|
+
stream.on('finish', () => resolve());
|
|
78
|
+
stream.on('error', (err) => reject(err));
|
|
79
|
+
stream.end(data);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
sftp.rename(tempPath, remotePath, (err) => {
|
|
84
|
+
if (err) {
|
|
85
|
+
// If rename fails, try unlink temp
|
|
86
|
+
sftp.unlink(tempPath, () => {});
|
|
87
|
+
return reject(err);
|
|
88
|
+
}
|
|
89
|
+
resolve({ success: true, bytes: data.length });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Create remote directory recursively
|
|
96
|
+
*/
|
|
97
|
+
async mkdir(profile, remotePath) {
|
|
98
|
+
const cmd = `mkdir -p ${JSON.stringify(remotePath)}`;
|
|
99
|
+
const res = await this.sshService.exec(profile, cmd);
|
|
100
|
+
if (res.code !== 0) {
|
|
101
|
+
throw new Error(`Failed to create remote dir ${remotePath}: ${res.stderr}`);
|
|
102
|
+
}
|
|
103
|
+
return { success: true };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Remove remote file or directory
|
|
108
|
+
*/
|
|
109
|
+
async remove(profile, remotePath, recursive = false) {
|
|
110
|
+
const cmd = recursive ? `rm -rf ${JSON.stringify(remotePath)}` : `rm -f ${JSON.stringify(remotePath)}`;
|
|
111
|
+
const res = await this.sshService.exec(profile, cmd);
|
|
112
|
+
if (res.code !== 0) {
|
|
113
|
+
throw new Error(`Failed to remove remote path ${remotePath}: ${res.stderr}`);
|
|
114
|
+
}
|
|
115
|
+
return { success: true };
|
|
116
|
+
}
|
|
117
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
function writeJson(res, code, data) {
|
|
2
|
+
try {
|
|
3
|
+
res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
4
|
+
res.end(JSON.stringify(data));
|
|
5
|
+
} catch {}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function readBody(req, maxBytes = 256 * 1024) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let size = 0;
|
|
12
|
+
req.on('data', (c) => {
|
|
13
|
+
size += c.length;
|
|
14
|
+
if (size > maxBytes) {
|
|
15
|
+
reject(new Error('Payload too large'));
|
|
16
|
+
req.destroy();
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
chunks.push(c);
|
|
20
|
+
});
|
|
21
|
+
req.on('end', () => {
|
|
22
|
+
try {
|
|
23
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
24
|
+
resolve(raw ? JSON.parse(raw) : {});
|
|
25
|
+
} catch (err) {
|
|
26
|
+
reject(new Error('Invalid JSON'));
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
req.on('error', reject);
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store) {
|
|
34
|
+
ctx.inject(['webServer'], (sctx) => {
|
|
35
|
+
// 1. GET /dsh-remote-workspace/state
|
|
36
|
+
sctx.effect(() => sctx.webServer.register({
|
|
37
|
+
kind: 'exact',
|
|
38
|
+
path: '/dsh-remote-workspace/state',
|
|
39
|
+
handler: async (req, res) => {
|
|
40
|
+
if (req.method !== 'GET') return writeJson(res, 405, { error: 'GET only' });
|
|
41
|
+
const profiles = store.getProfiles();
|
|
42
|
+
const activeId = store.getActiveId();
|
|
43
|
+
const tunnels = tunnelService.listActiveTunnels();
|
|
44
|
+
writeJson(res, 200, { ok: true, profiles, activeId, tunnels });
|
|
45
|
+
}
|
|
46
|
+
}), 'dsh-remote-workspace: /state');
|
|
47
|
+
|
|
48
|
+
// 2. POST /dsh-remote-workspace/profiles/save
|
|
49
|
+
sctx.effect(() => sctx.webServer.register({
|
|
50
|
+
kind: 'exact',
|
|
51
|
+
path: '/dsh-remote-workspace/profiles/save',
|
|
52
|
+
handler: async (req, res) => {
|
|
53
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
54
|
+
try {
|
|
55
|
+
const profile = await readBody(req);
|
|
56
|
+
if (!profile.id || !profile.host) {
|
|
57
|
+
return writeJson(res, 400, { ok: false, error: 'ID and Host are required' });
|
|
58
|
+
}
|
|
59
|
+
await store.saveProfile(profile);
|
|
60
|
+
writeJson(res, 200, { ok: true, profile });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}), 'dsh-remote-workspace: /profiles/save');
|
|
66
|
+
|
|
67
|
+
// 3. POST /dsh-remote-workspace/profiles/delete
|
|
68
|
+
sctx.effect(() => sctx.webServer.register({
|
|
69
|
+
kind: 'exact',
|
|
70
|
+
path: '/dsh-remote-workspace/profiles/delete',
|
|
71
|
+
handler: async (req, res) => {
|
|
72
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
73
|
+
try {
|
|
74
|
+
const { id } = await readBody(req);
|
|
75
|
+
if (!id) return writeJson(res, 400, { ok: false, error: 'ID is required' });
|
|
76
|
+
await store.deleteProfile(id);
|
|
77
|
+
writeJson(res, 200, { ok: true });
|
|
78
|
+
} catch (err) {
|
|
79
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}), 'dsh-remote-workspace: /profiles/delete');
|
|
83
|
+
|
|
84
|
+
// 4. POST /dsh-remote-workspace/profiles/active
|
|
85
|
+
sctx.effect(() => sctx.webServer.register({
|
|
86
|
+
kind: 'exact',
|
|
87
|
+
path: '/dsh-remote-workspace/profiles/active',
|
|
88
|
+
handler: async (req, res) => {
|
|
89
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
90
|
+
try {
|
|
91
|
+
const { id } = await readBody(req);
|
|
92
|
+
await store.setActiveId(id || null);
|
|
93
|
+
writeJson(res, 200, { ok: true, activeId: store.getActiveId() });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}), 'dsh-remote-workspace: /profiles/active');
|
|
99
|
+
|
|
100
|
+
// 5. POST /dsh-remote-workspace/test
|
|
101
|
+
sctx.effect(() => sctx.webServer.register({
|
|
102
|
+
kind: 'exact',
|
|
103
|
+
path: '/dsh-remote-workspace/test',
|
|
104
|
+
handler: async (req, res) => {
|
|
105
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
106
|
+
try {
|
|
107
|
+
const body = await readBody(req);
|
|
108
|
+
const profile = body.profile || (body.id ? store.getProfile(body.id) : body);
|
|
109
|
+
if (!profile || !profile.host) {
|
|
110
|
+
return writeJson(res, 400, { ok: false, error: 'Invalid profile configuration' });
|
|
111
|
+
}
|
|
112
|
+
const result = await sshService.testConnection(profile);
|
|
113
|
+
writeJson(res, 200, { ok: result.success, ...result });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
writeJson(res, 200, { ok: false, error: err.message });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}), 'dsh-remote-workspace: /test');
|
|
119
|
+
|
|
120
|
+
// 6. POST /dsh-remote-workspace/browse
|
|
121
|
+
sctx.effect(() => sctx.webServer.register({
|
|
122
|
+
kind: 'exact',
|
|
123
|
+
path: '/dsh-remote-workspace/browse',
|
|
124
|
+
handler: async (req, res) => {
|
|
125
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
126
|
+
try {
|
|
127
|
+
const body = await readBody(req);
|
|
128
|
+
const profile = body.profile || (body.profileId ? store.getProfile(body.profileId) : store.getActiveProfile());
|
|
129
|
+
if (!profile || !profile.host) {
|
|
130
|
+
return writeJson(res, 400, { ok: false, error: 'No profile specified' });
|
|
131
|
+
}
|
|
132
|
+
const remotePath = body.remotePath || '/';
|
|
133
|
+
const entries = await remoteFs.listDir(profile, remotePath);
|
|
134
|
+
writeJson(res, 200, { ok: true, currentPath: remotePath, entries });
|
|
135
|
+
} catch (err) {
|
|
136
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}), 'dsh-remote-workspace: /browse');
|
|
140
|
+
|
|
141
|
+
// 7. POST /dsh-remote-workspace/sync
|
|
142
|
+
sctx.effect(() => sctx.webServer.register({
|
|
143
|
+
kind: 'exact',
|
|
144
|
+
path: '/dsh-remote-workspace/sync',
|
|
145
|
+
handler: async (req, res) => {
|
|
146
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'POST only' });
|
|
147
|
+
try {
|
|
148
|
+
const { profileId, direction } = await readBody(req);
|
|
149
|
+
const profile = store.getProfile(profileId) || store.getActiveProfile();
|
|
150
|
+
if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
|
|
151
|
+
if (!profile.remoteWorkspace || !profile.localMirrorPath) {
|
|
152
|
+
return writeJson(res, 400, { ok: false, error: 'Both remoteWorkspace and localMirrorPath must be set' });
|
|
153
|
+
}
|
|
154
|
+
const resSync = direction === 'push'
|
|
155
|
+
? await mirrorSync.push(profile, profile.localMirrorPath, profile.remoteWorkspace)
|
|
156
|
+
: await mirrorSync.pull(profile, profile.remoteWorkspace, profile.localMirrorPath);
|
|
157
|
+
writeJson(res, 200, { ok: resSync.success, ...resSync });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
writeJson(res, 500, { ok: false, error: err.message });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}), 'dsh-remote-workspace: /sync');
|
|
163
|
+
});
|
|
164
|
+
}
|