@goodandready/dsh-remote-workspace 0.3.0 → 0.3.2

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.
@@ -0,0 +1,57 @@
1
+ export class DiagnoseService {
2
+ /**
3
+ * @param {import('./ssh-service.js').SshService} sshService
4
+ */
5
+ constructor(sshService) {
6
+ this.sshService = sshService;
7
+ }
8
+
9
+ /**
10
+ * Diagnose system aspects on remote host
11
+ * @param {any} profile
12
+ * @param {'ports'|'disk'|'memory'|'oom_killer'|'service_logs'} category
13
+ * @param {string} [target] optional port, service name, or path
14
+ */
15
+ async diagnose(profile, category, target = '') {
16
+ if (!profile || !profile.host) throw new Error('Invalid profile');
17
+
18
+ let cmd = '';
19
+ switch (category) {
20
+ case 'ports': {
21
+ const filter = target ? `grep ':${target} '` : 'cat';
22
+ cmd = `(ss -tulpn 2>/dev/null || netstat -tulpn 2>/dev/null || lsof -i -P -n 2>/dev/null) | ${filter} | head -n 40`;
23
+ break;
24
+ }
25
+ case 'disk': {
26
+ const path = target || '/';
27
+ cmd = `df -h ${path} 2>/dev/null; echo "---TOP 10 DIRS---"; du -ahx ${path} 2>/dev/null | sort -rh | head -n 10`;
28
+ break;
29
+ }
30
+ case 'memory': {
31
+ cmd = `free -h 2>/dev/null || free -m; echo "---TOP 10 PROCESSES BY RAM---"; ps aux --sort=-%mem 2>/dev/null | head -n 11`;
32
+ break;
33
+ }
34
+ case 'oom_killer': {
35
+ cmd = `(dmesg -T 2>/dev/null || dmesg 2>/dev/null) | grep -i -E 'oom|out of memory|killed process' | tail -n 25 || echo "No OOM events detected"`;
36
+ break;
37
+ }
38
+ case 'service_logs': {
39
+ if (!target) throw new Error('Target service name required for service_logs');
40
+ const safeName = target.replace(/[^a-zA-Z0-9_.-]/g, '');
41
+ cmd = `(journalctl -u ${safeName} -n 40 --no-pager 2>/dev/null || pm2 logs ${safeName} --lines 40 --nostream 2>/dev/null || tail -n 40 /var/log/${safeName}.log 2>/dev/null || echo "No logs found for ${safeName}")`;
42
+ break;
43
+ }
44
+ default:
45
+ throw new Error(`Unsupported diagnosis category: ${category}`);
46
+ }
47
+
48
+ const res = await this.sshService.exec(profile, cmd);
49
+ const code = res.code !== undefined ? res.code : (res.exitCode !== undefined ? res.exitCode : 0);
50
+ return {
51
+ ok: code === 0,
52
+ category,
53
+ target: target || null,
54
+ output: (res.stdout || res.stderr || '').trim()
55
+ };
56
+ }
57
+ }
@@ -38,7 +38,7 @@ export class DockerService {
38
38
  ports: item.Ports || '',
39
39
  created: item.CreatedAt || item.Created || ''
40
40
  });
41
- } catch (_) {}
41
+ } catch (err) { /* best-effort cleanup */ }
42
42
  }
43
43
 
44
44
  return {
@@ -0,0 +1,122 @@
1
+ export class EnvService {
2
+ /**
3
+ * @param {import('./remote-fs-service.js').RemoteFsService} remoteFsService
4
+ */
5
+ constructor(remoteFsService) {
6
+ this.remoteFsService = remoteFsService;
7
+ }
8
+
9
+ /**
10
+ * Parse .env text into structured entries (preserving comments and order)
11
+ * @param {string} text
12
+ */
13
+ parseEnv(text) {
14
+ if (!text) return [];
15
+ const lines = text.split('\n');
16
+ const result = [];
17
+
18
+ for (let i = 0; i < lines.length; i++) {
19
+ const line = lines[i];
20
+ const trimmed = line.trim();
21
+ if (!trimmed) {
22
+ result.push({ type: 'empty', raw: line });
23
+ continue;
24
+ }
25
+ if (trimmed.startsWith('#')) {
26
+ result.push({ type: 'comment', raw: line });
27
+ continue;
28
+ }
29
+
30
+ const eqIdx = line.indexOf('=');
31
+ if (eqIdx === -1) {
32
+ result.push({ type: 'raw', raw: line });
33
+ continue;
34
+ }
35
+
36
+ const key = line.slice(0, eqIdx).trim();
37
+ let val = line.slice(eqIdx + 1);
38
+ // Check if value is wrapped in quotes
39
+ let isQuoted = false;
40
+ const valTrimmed = val.trim();
41
+ if ((valTrimmed.startsWith('"') && valTrimmed.endsWith('"')) ||
42
+ (valTrimmed.startsWith("'") && valTrimmed.endsWith("'"))) {
43
+ isQuoted = true;
44
+ val = valTrimmed.slice(1, -1);
45
+ }
46
+
47
+ result.push({
48
+ type: 'var',
49
+ key,
50
+ value: val,
51
+ isQuoted,
52
+ raw: line
53
+ });
54
+ }
55
+
56
+ return result;
57
+ }
58
+
59
+ /**
60
+ * Read and parse remote .env file
61
+ */
62
+ async readEnv(profile, filePath) {
63
+ const content = await this.remoteFsService.readFile(profile, filePath);
64
+ const parsed = this.parseEnv(content);
65
+ return {
66
+ filePath,
67
+ entries: parsed,
68
+ raw: content
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Set or update variable in remote .env file atomically
74
+ */
75
+ async setEnvVar(profile, filePath, key, value) {
76
+ if (!key || typeof key !== 'string') throw new Error('Key must be non-empty string');
77
+
78
+ let currentContent = '';
79
+ try {
80
+ currentContent = await this.remoteFsService.readFile(profile, filePath);
81
+ } catch {
82
+ currentContent = '';
83
+ }
84
+
85
+ const lines = currentContent ? currentContent.split('\n') : [];
86
+ let updated = false;
87
+ const newLines = [];
88
+
89
+ for (const line of lines) {
90
+ const trimmed = line.trim();
91
+ if (!trimmed.startsWith('#') && trimmed.includes('=')) {
92
+ const k = trimmed.slice(0, trimmed.indexOf('=')).trim();
93
+ if (k === key) {
94
+ // Quote if contains spaces or special symbols
95
+ const formattedVal = (value.includes(' ') || value.includes('\n')) ? `"${value}"` : value;
96
+ newLines.push(`${key}=${formattedVal}`);
97
+ updated = true;
98
+ continue;
99
+ }
100
+ }
101
+ newLines.push(line);
102
+ }
103
+
104
+ if (!updated) {
105
+ const formattedVal = (value.includes(' ') || value.includes('\n')) ? `"${value}"` : value;
106
+ if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
107
+ newLines.push('');
108
+ }
109
+ newLines.push(`${key}=${formattedVal}`);
110
+ }
111
+
112
+ const finalContent = newLines.join('\n');
113
+ await this.remoteFsService.writeFile(profile, filePath, finalContent);
114
+
115
+ return {
116
+ ok: true,
117
+ filePath,
118
+ key,
119
+ updated
120
+ };
121
+ }
122
+ }
package/lib/index.js CHANGED
@@ -7,6 +7,10 @@ import { TunnelService } from './tunnel-service.js';
7
7
  import { DockerService } from './docker-service.js';
8
8
  import { HealthService } from './health-service.js';
9
9
  import { WatcherService } from './watcher-service.js';
10
+ import { TarSyncService } from './tar-sync-service.js';
11
+ import { DiagnoseService } from './diagnose-service.js';
12
+ import { EnvService } from './env-service.js';
13
+ import { AlertService } from './alert-service.js';
10
14
  import { registerRemoteTools } from './tools.js';
11
15
  import { registerApiRoutes } from './routes.js';
12
16
 
@@ -33,7 +37,8 @@ export const Config = z.object({
33
37
  jumpHostId: z.string().default('')
34
38
  })).default([]).description('List of configured remote machines and workspaces.'),
35
39
  activeProfileId: z.string().default('').description('ID of active remote profile.'),
36
- autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.')
40
+ autoSyncActive: z.boolean().default(false).description('Whether Live Auto-Sync watcher is enabled.'),
41
+ alertMonitoringActive: z.boolean().default(false).description('Whether background anomaly alerts are monitored.')
37
42
  });
38
43
 
39
44
  export function apply(ctx, config = {}) {
@@ -42,7 +47,8 @@ export function apply(ctx, config = {}) {
42
47
  let currentConfig = {
43
48
  profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
44
49
  activeProfileId: cfg.activeProfileId || null,
45
- autoSyncActive: Boolean(cfg.autoSyncActive)
50
+ autoSyncActive: Boolean(cfg.autoSyncActive),
51
+ alertMonitoringActive: Boolean(cfg.alertMonitoringActive)
46
52
  };
47
53
 
48
54
  const vaultService = new VaultService();
@@ -53,6 +59,10 @@ export function apply(ctx, config = {}) {
53
59
  const dockerService = new DockerService(sshService);
54
60
  const healthService = new HealthService(sshService);
55
61
  const watcherService = new WatcherService();
62
+ const tarSyncService = new TarSyncService(sshService);
63
+ const diagnoseService = new DiagnoseService(sshService);
64
+ const envService = new EnvService(remoteFs);
65
+ const alertService = new AlertService(ctx, healthService, dockerService);
56
66
 
57
67
  // Migrate any existing plain passwords into vault upon start
58
68
  for (const p of currentConfig.profiles) {
@@ -72,6 +82,19 @@ export function apply(ctx, config = {}) {
72
82
  }
73
83
  }
74
84
 
85
+ const persistConfig = async (actionLabel) => {
86
+ if (settingsApi?.replace) {
87
+ try {
88
+ await settingsApi.replace(Config(currentConfig));
89
+ } catch (err) {
90
+ if (ctx.logger?.warn) {
91
+ ctx.logger.warn(`[dsh-remote-workspace] Failed to persist config after ${actionLabel}: ${err.message}`);
92
+ }
93
+ throw new Error(`Failed to save configuration: ${err.message}`);
94
+ }
95
+ }
96
+ };
97
+
75
98
  const store = {
76
99
  vault: vaultService,
77
100
  getRawProfiles() { return currentConfig.profiles || []; },
@@ -95,9 +118,7 @@ export function apply(ctx, config = {}) {
95
118
  if (enabled) watcherService.start(active, mirrorSync);
96
119
  else watcherService.stop(active.id);
97
120
  }
98
- if (settingsApi?.replace) {
99
- try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
100
- }
121
+ await persistConfig('update');
101
122
  },
102
123
  async saveProfile(rawP) {
103
124
  const list = [...(currentConfig.profiles || [])];
@@ -130,9 +151,7 @@ export function apply(ctx, config = {}) {
130
151
  currentConfig.activeProfileId = p.id;
131
152
  }
132
153
  sshService.invalidate(p.id);
133
- if (settingsApi?.replace) {
134
- try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
135
- }
154
+ await persistConfig('update');
136
155
  },
137
156
  async deleteProfile(id) {
138
157
  watcherService.stop(id);
@@ -142,9 +161,7 @@ export function apply(ctx, config = {}) {
142
161
  }
143
162
  vaultService.deleteProfileSecrets(id);
144
163
  sshService.disconnect(id);
145
- if (settingsApi?.replace) {
146
- try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
147
- }
164
+ await persistConfig('update');
148
165
  },
149
166
  async setActiveId(id) {
150
167
  currentConfig.activeProfileId = id;
@@ -153,13 +170,34 @@ export function apply(ctx, config = {}) {
153
170
  const active = store.getActiveProfile();
154
171
  if (active) watcherService.start(active, mirrorSync);
155
172
  }
156
- if (settingsApi?.replace) {
157
- try { await settingsApi.replace(Config(currentConfig)); } catch (_) {}
158
- }
173
+ await persistConfig('update');
159
174
  }
160
175
  };
161
176
 
162
177
  sshService.setJumpProfileResolver((jumpId) => store.getProfile(jumpId));
178
+ sshService.setProfileResolver((id) => store.getProfile(id));
179
+
180
+ const safeProfilesService = {
181
+ list() {
182
+ return (store.getProfiles() || []).map((p) => ({
183
+ id: p.id,
184
+ name: p.name || p.host,
185
+ host: p.host,
186
+ port: p.port || 22,
187
+ username: p.username || 'root',
188
+ authType: p.authType || 'key'
189
+ }));
190
+ },
191
+ get(id) {
192
+ return store.getProfile(id);
193
+ },
194
+ getActiveId() {
195
+ return store.getActiveId();
196
+ },
197
+ getActive() {
198
+ return store.getActiveProfile();
199
+ }
200
+ };
163
201
 
164
202
  // If autoSync was enabled on restart, resume watcher for active profile
165
203
  if (currentConfig.autoSyncActive && currentConfig.activeProfileId) {
@@ -179,6 +217,7 @@ export function apply(ctx, config = {}) {
179
217
  if (Array.isArray(snap.profiles)) currentConfig.profiles = snap.profiles;
180
218
  if (snap.activeProfileId !== undefined) currentConfig.activeProfileId = snap.activeProfileId;
181
219
  if (snap.autoSyncActive !== undefined) currentConfig.autoSyncActive = snap.autoSyncActive;
220
+ if (snap.alertMonitoringActive !== undefined) currentConfig.alertMonitoringActive = snap.alertMonitoringActive;
182
221
  }
183
222
  sctx.effect(() => () => {
184
223
  settingsApi = null;
@@ -193,6 +232,12 @@ export function apply(ctx, config = {}) {
193
232
  ctx.provide('remoteDocker');
194
233
  ctx.provide('remoteHealth');
195
234
  ctx.provide('remoteWatcher');
235
+ ctx.provide('remoteTarSync');
236
+ ctx.provide('remoteDiagnose');
237
+ ctx.provide('remoteEnv');
238
+ ctx.provide('remoteAlert');
239
+ ctx.provide('remoteProfiles');
240
+ ctx.remoteProfiles = safeProfilesService;
196
241
 
197
242
  ctx.remoteSsh = sshService;
198
243
  ctx.remoteFs = remoteFs;
@@ -202,6 +247,10 @@ export function apply(ctx, config = {}) {
202
247
  ctx.remoteDocker = dockerService;
203
248
  ctx.remoteHealth = healthService;
204
249
  ctx.remoteWatcher = watcherService;
250
+ ctx.remoteTarSync = tarSyncService;
251
+ ctx.remoteDiagnose = diagnoseService;
252
+ ctx.remoteEnv = envService;
253
+ ctx.remoteAlert = alertService;
205
254
 
206
255
  // Tools registration
207
256
  registerRemoteTools(
@@ -212,14 +261,32 @@ export function apply(ctx, config = {}) {
212
261
  tunnelService,
213
262
  () => store.getActiveProfile(),
214
263
  (id) => store.getProfile(id),
215
- dockerService
264
+ dockerService,
265
+ diagnoseService,
266
+ envService,
267
+ tarSyncService
216
268
  );
217
269
 
218
270
  // API Routes
219
- registerApiRoutes(ctx, sshService, remoteFs, mirrorSync, tunnelService, store, dockerService, healthService, watcherService);
271
+ registerApiRoutes(
272
+ ctx,
273
+ sshService,
274
+ remoteFs,
275
+ mirrorSync,
276
+ tunnelService,
277
+ store,
278
+ dockerService,
279
+ healthService,
280
+ watcherService,
281
+ diagnoseService,
282
+ envService,
283
+ tarSyncService,
284
+ alertService
285
+ );
220
286
 
221
287
  // Cleanup on unload
222
288
  ctx.on('dispose', () => {
289
+ alertService.stop();
223
290
  watcherService.stopAll();
224
291
  tunnelService.stopAll();
225
292
  sshService.disconnectAll();
@@ -51,7 +51,7 @@ export class MirrorSyncService {
51
51
  if (fs.existsSync(snapshotFile)) {
52
52
  try {
53
53
  lastSnapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
54
- } catch (_) {}
54
+ } catch (err) { /* best-effort cleanup */ }
55
55
  }
56
56
 
57
57
  const conflicts = [];
@@ -172,7 +172,7 @@ export class MirrorSyncService {
172
172
  if (fs.existsSync(snapshotFile)) {
173
173
  try {
174
174
  snapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
175
- } catch (_) {}
175
+ } catch (err) { /* best-effort cleanup */ }
176
176
  }
177
177
 
178
178
  const pushed = [];
@@ -0,0 +1,85 @@
1
+ import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
2
+
3
+ export function registerDockerRoutes(sctx, webServer, { dockerService, healthService, store }) {
4
+ // 21. GET /dsh-remote-workspace/health
5
+ sctx.effect(() => webServer.register({
6
+ kind: 'exact',
7
+ path: '/dsh-remote-workspace/health',
8
+ handler: async (req, res) => {
9
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
10
+ try {
11
+ const url = new URL(req.url, 'http://localhost');
12
+ const profileId = url.searchParams.get('profileId');
13
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
14
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
15
+
16
+ const health = await healthService.getHealth(profile);
17
+ writeJson(res, 200, { ok: true, health });
18
+ } catch (err) {
19
+ writeJson(res, 500, { ok: false, error: err.message });
20
+ }
21
+ }
22
+ }), 'dsh-remote-workspace: /health');
23
+
24
+ // 22. GET /dsh-remote-workspace/docker/list
25
+ sctx.effect(() => webServer.register({
26
+ kind: 'exact',
27
+ path: '/dsh-remote-workspace/docker/list',
28
+ handler: async (req, res) => {
29
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
30
+ try {
31
+ const url = new URL(req.url, 'http://localhost');
32
+ const profileId = url.searchParams.get('profileId');
33
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
34
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
35
+
36
+ const result = await dockerService.listContainers(profile, true);
37
+ writeJson(res, 200, { ok: true, ...result });
38
+ } catch (err) {
39
+ writeJson(res, 500, { ok: false, error: err.message });
40
+ }
41
+ }
42
+ }), 'dsh-remote-workspace: /docker/list');
43
+
44
+ // 23. POST /dsh-remote-workspace/docker/action
45
+ sctx.effect(() => webServer.register({
46
+ kind: 'exact',
47
+ path: '/dsh-remote-workspace/docker/action',
48
+ handler: async (req, res) => {
49
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
50
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
51
+ try {
52
+ const { profileId, containerId, action } = await readBody(req);
53
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
54
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
55
+ if (!containerId || !action) return writeJson(res, 400, { ok: false, error: 'containerId and action required' });
56
+
57
+ const result = await dockerService.containerAction(profile, containerId, action);
58
+ writeJson(res, 200, { ok: result.success, ...result });
59
+ } catch (err) {
60
+ writeJson(res, 500, { ok: false, error: err.message });
61
+ }
62
+ }
63
+ }), 'dsh-remote-workspace: /docker/action');
64
+
65
+ // 24. POST /dsh-remote-workspace/docker/logs
66
+ sctx.effect(() => webServer.register({
67
+ kind: 'exact',
68
+ path: '/dsh-remote-workspace/docker/logs',
69
+ handler: async (req, res) => {
70
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
71
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
72
+ try {
73
+ const { profileId, containerId, tail } = await readBody(req);
74
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
75
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'No active profile' });
76
+ if (!containerId) return writeJson(res, 400, { ok: false, error: 'containerId required' });
77
+
78
+ const result = await dockerService.containerLogs(profile, containerId, tail || 100);
79
+ writeJson(res, 200, { ok: result.success, ...result });
80
+ } catch (err) {
81
+ writeJson(res, 500, { ok: false, error: err.message });
82
+ }
83
+ }
84
+ }), 'dsh-remote-workspace: /docker/logs');
85
+ }
@@ -0,0 +1,60 @@
1
+ import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
2
+
3
+ export function registerEnvRoutes(sctx, webServer, { envService, diagnoseService, sshService, store }) {
4
+ // 10. POST /dsh-remote-workspace/env/view
5
+ sctx.effect(() => webServer.register({
6
+ kind: 'exact',
7
+ path: '/dsh-remote-workspace/env/view',
8
+ handler: async (req, res) => {
9
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
+ try {
12
+ const { profileId, remotePath } = await readBody(req);
13
+ const profile = store.getProfile(profileId);
14
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
15
+ const result = await envService.readRemoteEnv(sshService, profile, remotePath);
16
+ writeJson(res, 200, { ok: true, ...result });
17
+ } catch (err) {
18
+ writeJson(res, 500, { ok: false, error: err.message });
19
+ }
20
+ }
21
+ }), 'dsh-remote-workspace: /env/view');
22
+
23
+ // 11. POST /dsh-remote-workspace/env/save
24
+ sctx.effect(() => webServer.register({
25
+ kind: 'exact',
26
+ path: '/dsh-remote-workspace/env/save',
27
+ handler: async (req, res) => {
28
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
29
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
30
+ try {
31
+ const { profileId, remotePath, content } = await readBody(req);
32
+ const profile = store.getProfile(profileId);
33
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
34
+ const result = await envService.saveRemoteEnv(sshService, profile, remotePath, content);
35
+ writeJson(res, 200, { ok: true, ...result });
36
+ } catch (err) {
37
+ writeJson(res, 500, { ok: false, error: err.message });
38
+ }
39
+ }
40
+ }), 'dsh-remote-workspace: /env/save');
41
+
42
+ // 12. POST /dsh-remote-workspace/diagnose
43
+ sctx.effect(() => webServer.register({
44
+ kind: 'exact',
45
+ path: '/dsh-remote-workspace/diagnose',
46
+ handler: async (req, res) => {
47
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
48
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
49
+ try {
50
+ const { profileId, checks } = await readBody(req);
51
+ const profile = store.getProfile(profileId);
52
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
53
+ const results = await diagnoseService.diagnose(sshService, profile, checks);
54
+ writeJson(res, 200, { ok: true, results });
55
+ } catch (err) {
56
+ writeJson(res, 500, { ok: false, error: err.message });
57
+ }
58
+ }
59
+ }), 'dsh-remote-workspace: /diagnose');
60
+ }
@@ -0,0 +1,88 @@
1
+ import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
2
+
3
+ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshService }) {
4
+ // 7. POST /dsh-remote-workspace/browse
5
+ sctx.effect(() => webServer.register({
6
+ kind: 'exact',
7
+ path: '/dsh-remote-workspace/browse',
8
+ handler: async (req, res) => {
9
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
10
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
11
+ try {
12
+ const { profileId, remotePath } = await readBody(req);
13
+ const profile = store.getProfile(profileId);
14
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
15
+ const items = await remoteFs.list(profile, remotePath || profile.remotePath || '.');
16
+ writeJson(res, 200, { ok: true, items });
17
+ } catch (err) {
18
+ writeJson(res, 500, { ok: false, error: err.message });
19
+ }
20
+ }
21
+ }), 'dsh-remote-workspace: /browse');
22
+
23
+ // 8. POST /dsh-remote-workspace/file/view
24
+ sctx.effect(() => webServer.register({
25
+ kind: 'exact',
26
+ path: '/dsh-remote-workspace/file/view',
27
+ handler: async (req, res) => {
28
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
29
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
30
+ try {
31
+ const { profileId, filePath } = await readBody(req);
32
+ const profile = store.getProfile(profileId);
33
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
34
+ const stat = await remoteFs.stat(profile, filePath);
35
+ if (stat.size > 2 * 1024 * 1024) {
36
+ return writeJson(res, 400, { ok: false, error: 'File exceeds 2MB preview limit' });
37
+ }
38
+ const buf = await remoteFs.readFile(profile, filePath);
39
+ writeJson(res, 200, { ok: true, content: buf.toString('utf8'), size: stat.size });
40
+ } catch (err) {
41
+ writeJson(res, 500, { ok: false, error: err.message });
42
+ }
43
+ }
44
+ }), 'dsh-remote-workspace: /file/view');
45
+
46
+ // 9. POST /dsh-remote-workspace/file/save
47
+ sctx.effect(() => webServer.register({
48
+ kind: 'exact',
49
+ path: '/dsh-remote-workspace/file/save',
50
+ handler: async (req, res) => {
51
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
52
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
53
+ try {
54
+ const { profileId, filePath, content } = await readBody(req);
55
+ const profile = store.getProfile(profileId);
56
+ if (!profile) return writeJson(res, 404, { ok: false, error: 'Profile not found' });
57
+ await remoteFs.writeFile(profile, filePath, Buffer.from(content, 'utf8'));
58
+ writeJson(res, 200, { ok: true });
59
+ } catch (err) {
60
+ writeJson(res, 500, { ok: false, error: err.message });
61
+ }
62
+ }
63
+ }), 'dsh-remote-workspace: /file/save');
64
+
65
+ // 26. POST /dsh-remote-workspace/transfer
66
+ sctx.effect(() => webServer.register({
67
+ kind: 'exact',
68
+ path: '/dsh-remote-workspace/transfer',
69
+ handler: async (req, res) => {
70
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
71
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
72
+ try {
73
+ const { sourceProfileId, sourcePath, destProfileId, destPath } = await readBody(req);
74
+ const srcProfile = sourceProfileId ? store.getProfile(sourceProfileId) : store.getActiveProfile();
75
+ const destProfile = store.getProfile(destProfileId);
76
+
77
+ if (!srcProfile) return writeJson(res, 404, { ok: false, error: 'Source profile not found' });
78
+ if (!destProfile) return writeJson(res, 404, { ok: false, error: 'Destination profile not found' });
79
+ if (!sourcePath || !destPath) return writeJson(res, 400, { ok: false, error: 'sourcePath and destPath required' });
80
+
81
+ const result = await sshService.transferFileBetweenHosts(srcProfile, sourcePath, destProfile, destPath);
82
+ writeJson(res, 200, { ok: result.success, ...result });
83
+ } catch (err) {
84
+ writeJson(res, 500, { ok: false, error: err.message });
85
+ }
86
+ }
87
+ }), 'dsh-remote-workspace: /transfer');
88
+ }
@@ -0,0 +1,40 @@
1
+ export function writeJson(res, statusCode, body) {
2
+ if (typeof res.setHeader === 'function') {
3
+ res.setHeader('Content-Type', 'application/json; charset=utf-8');
4
+ res.statusCode = statusCode;
5
+ } else if (typeof res.writeHead === 'function') {
6
+ res.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
7
+ } else {
8
+ res.statusCode = statusCode;
9
+ }
10
+ res.end(JSON.stringify(body));
11
+ }
12
+
13
+ export function readBody(req) {
14
+ return new Promise((resolve, reject) => {
15
+ let body = '';
16
+ req.on('data', (chunk) => { body += chunk.toString(); });
17
+ req.on('end', () => {
18
+ try {
19
+ resolve(body ? JSON.parse(body) : {});
20
+ } catch (err) {
21
+ reject(err);
22
+ }
23
+ });
24
+ req.on('error', reject);
25
+ });
26
+ }
27
+
28
+ export function isTrustedSettingsRequest(req) {
29
+ const rawAuth = req.headers['authorization'];
30
+ if (typeof rawAuth === 'string' && rawAuth.startsWith('Bearer ')) return true;
31
+ const cookieHeader = req.headers['cookie'];
32
+ if (typeof cookieHeader === 'string' && (cookieHeader.includes('token=') || cookieHeader.includes('dsh_token='))) {
33
+ return true;
34
+ }
35
+ const secFetch = req.headers['sec-fetch-site'];
36
+ if (secFetch === 'same-origin') return true;
37
+ const ip = req.socket?.remoteAddress || '';
38
+ if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return true;
39
+ return false;
40
+ }