@goodandready/dsh-remote-workspace 0.3.7 → 0.3.9

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 function filterClusterProfiles(profiles, filter = {}) {
2
+ const aliases = new Set((filter.aliases || []).map((item) => String(item)));
3
+ const tags = (filter.tags || []).map((item) => String(item).toLowerCase()).filter(Boolean);
4
+ const environment = String(filter.environment || '').trim().toLowerCase();
5
+ return (profiles || []).filter((profile) => {
6
+ if (aliases.size && !aliases.has(String(profile.id)) && !aliases.has(String(profile.name || ''))) return false;
7
+ if (environment && String(profile.environment || '').toLowerCase() !== environment) return false;
8
+ if (tags.length) {
9
+ const have = (Array.isArray(profile.tags) ? profile.tags : []).map((item) => String(item).toLowerCase());
10
+ if (!tags.every((tag) => have.includes(tag))) return false;
11
+ }
12
+ return true;
13
+ });
14
+ }
15
+
16
+ export async function runCluster(profiles, command, options = {}) {
17
+ const list = profiles || [];
18
+ if (!list.length) return [];
19
+ const limit = Math.max(1, Math.min(Number(options.maxWorkers) || 8, list.length));
20
+ const exec = options.exec;
21
+ const results = new Array(list.length);
22
+ let cursor = 0;
23
+ const worker = async () => {
24
+ while (cursor < list.length) {
25
+ const index = cursor;
26
+ cursor += 1;
27
+ const profile = list[index];
28
+ const started = Date.now();
29
+ try {
30
+ const res = await exec(profile, command);
31
+ results[index] = {
32
+ alias: profile.name || profile.id || '',
33
+ host: profile.host || '',
34
+ success: (res.code ?? 0) === 0,
35
+ exitCode: res.code ?? 0,
36
+ durationMs: Date.now() - started,
37
+ stdout: res.stdout || '',
38
+ stderr: res.stderr || '',
39
+ error: ''
40
+ };
41
+ } catch (err) {
42
+ results[index] = {
43
+ alias: profile.name || profile.id || '',
44
+ host: profile.host || '',
45
+ success: false,
46
+ exitCode: null,
47
+ durationMs: Date.now() - started,
48
+ stdout: '',
49
+ stderr: '',
50
+ error: err && err.message ? err.message : 'failed'
51
+ };
52
+ }
53
+ }
54
+ };
55
+ await Promise.all(Array.from({ length: limit }, () => worker()));
56
+ return results;
57
+ }
@@ -0,0 +1,24 @@
1
+ export function isRetryableConnectionError(err) {
2
+ if (!err || err.commandTimeout) return false;
3
+ if (err.stdout) return false;
4
+ const code = err.code || '';
5
+ if (['ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENOTFOUND', 'EPIPE'].includes(code)) return true;
6
+ return /connection reset|not connected|No response from server|socket hang up/i.test(String(err.message || ''));
7
+ }
8
+
9
+ export async function runWithReconnect(run, options = {}) {
10
+ const attempts = options.idempotent === false ? 1 : 3;
11
+ const wait = options.wait || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
12
+ let lastError = null;
13
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
14
+ try {
15
+ return await run();
16
+ } catch (err) {
17
+ lastError = err;
18
+ if (attempt >= attempts || !isRetryableConnectionError(err)) throw err;
19
+ if (options.onRetry) options.onRetry(err, attempt);
20
+ await wait(50 * attempt);
21
+ }
22
+ }
23
+ throw lastError;
24
+ }
@@ -0,0 +1,56 @@
1
+ const MAX_BYTES = 512 * 1024 * 1024;
2
+
3
+ export function transferPercent(loaded, total) {
4
+ const done = Number(loaded) || 0;
5
+ const size = Number(total) || 0;
6
+ if (size <= 0) return 0;
7
+ return Math.max(0, Math.min(100, Math.round((done / size) * 100)));
8
+ }
9
+
10
+ export function attachmentName(filePath) {
11
+ const base = String(filePath || '').split('/').filter(Boolean).pop() || 'download';
12
+ const clean = base.replace(/[\r\n"\\]/g, '');
13
+ if (!clean || clean === '.' || clean === '..') return 'download';
14
+ return clean.slice(0, 180);
15
+ }
16
+
17
+ export function contentDisposition(filePath) {
18
+ const name = attachmentName(filePath);
19
+ const ascii = name.replace(/[^\x20-\x7E]/g, '_') || 'download';
20
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`;
21
+ }
22
+
23
+ export function pipeWithProgress(source, dest) {
24
+ return new Promise((resolve, reject) => {
25
+ let loaded = 0;
26
+ let settled = false;
27
+ const finish = (err) => {
28
+ if (settled) return;
29
+ settled = true;
30
+ if (err) reject(err);
31
+ else resolve(loaded);
32
+ };
33
+ source.on('data', (chunk) => {
34
+ loaded += chunk.length;
35
+ if (loaded > MAX_BYTES) {
36
+ const err = new Error('File exceeds the 512MB transfer limit');
37
+ err.code = 'LIMIT';
38
+ source.destroy();
39
+ dest.destroy();
40
+ finish(err);
41
+ }
42
+ });
43
+ source.on('error', finish);
44
+ dest.on('error', finish);
45
+ dest.on('finish', () => finish());
46
+ source.on('aborted', () => {
47
+ const err = new Error('Transfer aborted');
48
+ err.code = 'ABORTED';
49
+ dest.destroy();
50
+ finish(err);
51
+ });
52
+ source.pipe(dest);
53
+ });
54
+ }
55
+
56
+ export { MAX_BYTES };
@@ -0,0 +1,42 @@
1
+ const SECRET = ['password', 'privateKey', 'privateKeyPath', 'passphrase', 'agentPath', 'proxyCommand'];
2
+
3
+ /**
4
+ * One host row safe to place in a model context.
5
+ * @param {any} profile
6
+ */
7
+ export function toAgentHostRow(profile) {
8
+ const jump = Boolean(
9
+ (typeof profile?.jumpHostId === 'string' && profile.jumpHostId.trim())
10
+ || (Array.isArray(profile?.jumpHosts) && profile.jumpHosts.length)
11
+ );
12
+ const proxy = Boolean(typeof profile?.proxyCommand === 'string' && profile.proxyCommand.trim());
13
+ const access = [jump ? 'jump' : '', proxy ? 'proxy' : ''].filter(Boolean).join(',') || 'direct';
14
+ return {
15
+ alias: String(profile?.name || profile?.id || ''),
16
+ host: String(profile?.host || ''),
17
+ port: profile?.port || 22,
18
+ user: String(profile?.username || ''),
19
+ auth: String(profile?.authType || 'key'),
20
+ access,
21
+ env: String(profile?.environment || ''),
22
+ tags: Array.isArray(profile?.tags) ? profile.tags.map((item) => String(item)).join(',') : '',
23
+ description: String(profile?.description || '')
24
+ };
25
+ }
26
+
27
+ /**
28
+ * @param {any[]} profiles
29
+ */
30
+ export function renderHostTable(profiles) {
31
+ const header = 'alias | host | port | user | auth | jump/proxy | env | tags | description';
32
+ const lines = (profiles || []).map((profile) => {
33
+ const row = toAgentHostRow(profile);
34
+ return [row.alias, row.host, row.port, row.user, row.auth, row.access, row.env, row.tags, row.description].join(' | ');
35
+ });
36
+ return [header, ...lines].join('\n');
37
+ }
38
+
39
+ export function hostProjectionLeaks(value) {
40
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
41
+ return SECRET.some((key) => text.includes(key));
42
+ }
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
+ import { normalizeTerminalFont } from './terminal-font.js';
2
3
  import { VaultService } from './vault-service.js';
3
4
  import { SshService } from './ssh-service.js';
4
5
  import { RemoteFsService } from './remote-fs-service.js';
@@ -28,17 +29,25 @@ export const Config = z.object({
28
29
  port: z.number().default(22),
29
30
  username: z.string().default('root'),
30
31
  authType: z.string().default('key'),
32
+ agentPath: z.string().default(''),
31
33
  privateKeyPath: z.string().default(''),
32
34
  privateKey: z.string().default(''),
33
35
  passphrase: z.string().default(''),
34
36
  password: z.string().default(''),
35
37
  remoteWorkspace: z.string().default(''),
36
38
  localMirrorPath: z.string().default(''),
37
- jumpHostId: z.string().default('')
39
+ jumpHostId: z.string().default(''),
40
+ jumpHosts: z.array(z.string()).default([]),
41
+ proxyCommand: z.string().default(''),
42
+ environment: z.string().default(''),
43
+ tags: z.array(z.string()).default([]),
44
+ location: z.string().default(''),
45
+ description: z.string().default('')
38
46
  })).default([]).description('List of configured remote machines and workspaces.'),
39
47
  activeProfileId: z.string().default('').description('ID of active remote profile.'),
40
48
  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.')
49
+ alertMonitoringActive: z.boolean().default(false).description('Whether background anomaly alerts are monitored.'),
50
+ terminalFontFamily: z.string().default('').description('CSS font family used by the terminal. Empty keeps the default monospace stack.')
42
51
  });
43
52
 
44
53
  export function apply(ctx, config = {}) {
@@ -48,11 +57,14 @@ export function apply(ctx, config = {}) {
48
57
  profiles: Array.isArray(cfg.profiles) ? cfg.profiles : [],
49
58
  activeProfileId: cfg.activeProfileId || null,
50
59
  autoSyncActive: Boolean(cfg.autoSyncActive),
51
- alertMonitoringActive: Boolean(cfg.alertMonitoringActive)
60
+ alertMonitoringActive: Boolean(cfg.alertMonitoringActive),
61
+ terminalFontFamily: typeof cfg.terminalFontFamily === 'string' ? cfg.terminalFontFamily : ''
52
62
  };
53
63
 
54
64
  const vaultService = new VaultService();
55
65
  const sshService = new SshService(ctx, vaultService);
66
+ const idleTimer = setInterval(() => sshService.sweepIdle(), 60 * 1000);
67
+ idleTimer.unref?.();
56
68
  const remoteFs = new RemoteFsService(sshService);
57
69
  const mirrorSync = new MirrorSyncService(remoteFs, sshService);
58
70
  const tunnelService = new TunnelService(sshService);
@@ -111,6 +123,12 @@ export function apply(ctx, config = {}) {
111
123
  return p ? vaultService.hydrateProfile(p) : undefined;
112
124
  },
113
125
  isAutoSyncEnabled() { return Boolean(currentConfig.autoSyncActive); },
126
+ getTerminalFont() { return currentConfig.terminalFontFamily || ''; },
127
+ async setTerminalFont(fontFamily) {
128
+ currentConfig.terminalFontFamily = normalizeTerminalFont(fontFamily);
129
+ await persistConfig('terminal-font');
130
+ return currentConfig.terminalFontFamily;
131
+ },
114
132
  async setAutoSyncEnabled(enabled) {
115
133
  currentConfig.autoSyncActive = Boolean(enabled);
116
134
  const active = store.getActiveProfile();
@@ -264,7 +282,8 @@ export function apply(ctx, config = {}) {
264
282
  dockerService,
265
283
  diagnoseService,
266
284
  envService,
267
- tarSyncService
285
+ tarSyncService,
286
+ () => store.getProfiles()
268
287
  );
269
288
 
270
289
  // API Routes
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Jump ids from an explicit list, or from a comma-separated jumpHostId.
3
+ * @param {any} profile
4
+ * @returns {string[]}
5
+ */
6
+ export function resolveJumpHops(profile) {
7
+ if (Array.isArray(profile?.jumpHosts) && profile.jumpHosts.length) {
8
+ return profile.jumpHosts.map((item) => String(item).trim()).filter(Boolean);
9
+ }
10
+ if (typeof profile?.jumpHostId === 'string' && profile.jumpHostId.trim()) {
11
+ return profile.jumpHostId.split(',').map((item) => item.trim()).filter(Boolean);
12
+ }
13
+ return [];
14
+ }
15
+
16
+ /**
17
+ * @param {any} client
18
+ * @param {string} host
19
+ * @param {number} port
20
+ */
21
+ export function forwardOut(client, host, port) {
22
+ return new Promise((resolve, reject) => {
23
+ client.forwardOut('127.0.0.1', 0, host, port || 22, (err, stream) => {
24
+ if (err) reject(err);
25
+ else resolve(stream);
26
+ });
27
+ });
28
+ }
29
+
30
+ /**
31
+ * Connect through bastions in order. Only clients created after the first hop
32
+ * are released with the target; the first hop stays in the shared pool.
33
+ */
34
+ export async function openJumpChain(target, hopProfiles, deps) {
35
+ if (!hopProfiles.length) throw new Error('Jump chain is empty');
36
+ const intermediates = [];
37
+ const release = () => {
38
+ for (const extra of intermediates) {
39
+ try { extra.end(); } catch { /* already closed */ }
40
+ }
41
+ };
42
+ try {
43
+ let client = await deps.connectPooled(hopProfiles[0]);
44
+ for (let i = 1; i < hopProfiles.length; i += 1) {
45
+ const hop = hopProfiles[i];
46
+ const stream = await forwardOut(client, hop.host, hop.port || 22);
47
+ client = await deps.connectDedicated(hop, stream);
48
+ intermediates.push(client);
49
+ }
50
+ const sock = await forwardOut(client, target.host, target.port || 22);
51
+ return { sock, release };
52
+ } catch (err) {
53
+ release();
54
+ throw err;
55
+ }
56
+ }
@@ -0,0 +1,5 @@
1
+ export function shouldPoll({ hidden = false, inFlight = false } = {}) {
2
+ if (hidden) return false;
3
+ if (inFlight) return false;
4
+ return true;
5
+ }
@@ -0,0 +1,60 @@
1
+ export function normalizeTags(value) {
2
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean);
3
+ return String(value || '').split(',').map((item) => item.trim()).filter(Boolean);
4
+ }
5
+
6
+ function bucket(map, key, profile) {
7
+ const name = key || 'ungrouped';
8
+ if (!map.has(name)) map.set(name, []);
9
+ map.get(name).push(profile);
10
+ }
11
+
12
+ export function groupProfiles(profiles, mode) {
13
+ const list = Array.isArray(profiles) ? profiles : [];
14
+ if (mode === 'environment') {
15
+ const map = new Map();
16
+ for (const profile of list) bucket(map, String(profile.environment || '').trim(), profile);
17
+ return [...map.entries()].map(([key, items]) => ({ key, items }));
18
+ }
19
+ if (mode === 'tag') {
20
+ const map = new Map();
21
+ for (const profile of list) {
22
+ const tags = normalizeTags(profile.tags);
23
+ if (!tags.length) bucket(map, '', profile);
24
+ else for (const tag of tags) bucket(map, tag, profile);
25
+ }
26
+ return [...map.entries()].map(([key, items]) => ({ key, items }));
27
+ }
28
+ return [{ key: 'all', items: list }];
29
+ }
30
+
31
+ export async function testProfileGroup(profiles, testOne) {
32
+ const results = [];
33
+ const queue = [...(profiles || [])];
34
+ const workers = Array.from({ length: Math.min(4, Math.max(queue.length, 1)) }, async () => {
35
+ while (queue.length) {
36
+ const profile = queue.shift();
37
+ if (!profile) return;
38
+ const started = Date.now();
39
+ try {
40
+ const result = await testOne(profile);
41
+ results.push({
42
+ id: profile.id,
43
+ name: profile.name || profile.host,
44
+ ok: true,
45
+ latencyMs: result.latencyMs ?? result.latency ?? (Date.now() - started)
46
+ });
47
+ } catch (err) {
48
+ results.push({
49
+ id: profile.id,
50
+ name: profile.name || profile.host,
51
+ ok: false,
52
+ error: err && err.message ? err.message : 'Connection failed'
53
+ });
54
+ }
55
+ }
56
+ });
57
+ if (!queue.length) return results;
58
+ await Promise.all(workers);
59
+ return results;
60
+ }
@@ -0,0 +1,53 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { Duplex } from 'node:stream';
3
+
4
+ const TOKEN = /%[%hprn]/g;
5
+
6
+ /**
7
+ * Expand OpenSSH ProxyCommand tokens. %% stays a literal percent.
8
+ * @param {string} template
9
+ * @param {{ host?: string, port?: number|string, user?: string, alias?: string }} tokens
10
+ */
11
+ export function expandProxyCommand(template, tokens = {}) {
12
+ const map = {
13
+ '%h': String(tokens.host ?? ''),
14
+ '%p': String(tokens.port ?? ''),
15
+ '%r': String(tokens.user ?? ''),
16
+ '%n': String(tokens.alias ?? ''),
17
+ '%%': '%'
18
+ };
19
+ return String(template ?? '').replace(TOKEN, (token) => (token in map ? map[token] : token));
20
+ }
21
+
22
+ /**
23
+ * Run a ProxyCommand and expose its stdio as a duplex socket for ssh2.
24
+ * POSIX uses `$SHELL -c`; Windows uses `cmd.exe /d /s /c`.
25
+ * @param {string} command
26
+ */
27
+ export function openProxyCommand(command) {
28
+ const windows = process.platform === 'win32';
29
+ const shell = windows ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/sh');
30
+ const args = windows ? ['/d', '/s', '/c', command] : ['-c', command];
31
+ const child = spawn(shell, args, { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true });
32
+ const sock = Duplex.from({ writable: child.stdin, readable: child.stdout });
33
+ let stopped = false;
34
+
35
+ const stop = () => {
36
+ if (stopped) return;
37
+ stopped = true;
38
+ if (child.exitCode !== null || child.signalCode) return;
39
+ child.kill('SIGTERM');
40
+ const timer = setTimeout(() => {
41
+ if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL');
42
+ }, 2000);
43
+ timer.unref?.();
44
+ child.once('exit', () => clearTimeout(timer));
45
+ };
46
+
47
+ sock.on('close', stop);
48
+ child.on('error', (err) => sock.destroy(err));
49
+ child.on('exit', () => {
50
+ if (!sock.destroyed) sock.destroy();
51
+ });
52
+ return { sock, child, stop };
53
+ }
@@ -117,6 +117,31 @@ export class RemoteFsService {
117
117
  return sftp.createReadStream(remotePath);
118
118
  }
119
119
 
120
+ async beginUpload(profile, remotePath) {
121
+ const sftp = await this.sshService.getSftp(profile);
122
+ const tempPath = `${remotePath}.dsh-tmp.${Date.now()}`;
123
+ const stream = sftp.createWriteStream(tempPath);
124
+ return {
125
+ stream,
126
+ commit() {
127
+ return new Promise((resolve, reject) => {
128
+ sftp.rename(tempPath, remotePath, (err) => {
129
+ if (err) {
130
+ sftp.unlink(tempPath, () => {});
131
+ return reject(err);
132
+ }
133
+ resolve();
134
+ });
135
+ });
136
+ },
137
+ abort() {
138
+ return new Promise((resolve) => {
139
+ sftp.unlink(tempPath, () => resolve());
140
+ });
141
+ }
142
+ };
143
+ }
144
+
120
145
  /**
121
146
  * Atomic file write using temp file and rename
122
147
  */
@@ -1,4 +1,24 @@
1
1
  import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
2
+ import { contentDisposition, pipeWithProgress, MAX_BYTES } from '../file-transfer.js';
3
+
4
+
5
+ function transferTarget(req, store) {
6
+ const url = new URL(req.url || '/', 'http://localhost');
7
+ const profileId = url.searchParams.get('profileId') || '';
8
+ const filePath = url.searchParams.get('filePath') || '';
9
+ if (!filePath || filePath.length > 1024 || filePath.includes('\0')) {
10
+ const err = new Error('A file path is required');
11
+ err.status = 400;
12
+ throw err;
13
+ }
14
+ const profile = profileId ? store.getProfile(profileId) : store.getActiveProfile();
15
+ if (!profile) {
16
+ const err = new Error('Profile not found');
17
+ err.status = 404;
18
+ throw err;
19
+ }
20
+ return { profile, filePath };
21
+ }
2
22
 
3
23
  export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshService }) {
4
24
  // 7. POST /dsh-remote-workspace/browse
@@ -75,6 +95,61 @@ export function registerFileRoutes(sctx, webServer, { remoteFs, store, sshServic
75
95
  }
76
96
  }), 'dsh-remote-workspace: /file/save');
77
97
 
98
+ sctx.effect(() => webServer.register({
99
+ kind: 'exact',
100
+ path: '/dsh-remote-workspace/file/download',
101
+ handler: async (req, res) => {
102
+ if (req.method !== 'GET') return writeJson(res, 405, { ok: false, error: 'GET only' });
103
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
104
+ try {
105
+ const { profile, filePath } = transferTarget(req, store);
106
+ const stat = await remoteFs.stat(profile, filePath);
107
+ if (!stat.isFile) return writeJson(res, 400, { ok: false, error: 'Only a file can be downloaded' });
108
+ if (stat.size > MAX_BYTES) return writeJson(res, 413, { ok: false, error: 'File exceeds the 512MB transfer limit' });
109
+ const stream = await remoteFs.createReadStream(profile, filePath);
110
+ const stop = () => { try { stream.destroy(); } catch (err) { /* best-effort cleanup */ } };
111
+ req.on('close', stop);
112
+ stream.on('error', () => stop());
113
+ res.writeHead(200, {
114
+ 'Content-Type': 'application/octet-stream',
115
+ 'Content-Length': stat.size,
116
+ 'Content-Disposition': contentDisposition(filePath),
117
+ 'Cache-Control': 'no-store'
118
+ });
119
+ stream.pipe(res);
120
+ } catch (err) {
121
+ if (!res.headersSent) writeJson(res, err.status || 500, { ok: false, error: err.message });
122
+ }
123
+ }
124
+ }), 'dsh-remote-workspace: /file/download');
125
+
126
+ sctx.effect(() => webServer.register({
127
+ kind: 'exact',
128
+ path: '/dsh-remote-workspace/file/upload',
129
+ handler: async (req, res) => {
130
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
131
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
132
+ let upload = null;
133
+ try {
134
+ const { profile, filePath } = transferTarget(req, store);
135
+ upload = await remoteFs.beginUpload(profile, filePath);
136
+ const bytes = await pipeWithProgress(req, upload.stream);
137
+ await upload.commit();
138
+ if (remoteFs.invalidateCache) remoteFs.invalidateCache(profile.id);
139
+ writeJson(res, 200, { ok: true, bytes });
140
+ } catch (err) {
141
+ if (upload) {
142
+ try { await upload.abort(); } catch (abortErr) { /* best-effort cleanup */ }
143
+ }
144
+ if (!res.headersSent) {
145
+ const status = err.code === 'ABORTED' ? 499 : (err.code === 'LIMIT' ? 413 : (err.status || 500));
146
+ writeJson(res, status, { ok: false, error: err.message });
147
+ }
148
+ }
149
+ }
150
+ }), 'dsh-remote-workspace: /file/upload');
151
+
152
+
78
153
  // 26. POST /dsh-remote-workspace/transfer
79
154
  sctx.effect(() => webServer.register({
80
155
  kind: 'exact',
@@ -1,5 +1,7 @@
1
1
  import { writeJson, readBody, isTrustedSettingsRequest } from './helpers.js';
2
2
  import { SshConfigParser } from '../ssh-config-parser.js';
3
+ import { testProfileGroup } from '../profile-groups.js';
4
+ import { filterClusterProfiles, runCluster } from '../cluster-service.js';
3
5
 
4
6
  function maskProfile(p) {
5
7
  if (!p || typeof p !== 'object') return p;
@@ -24,8 +26,10 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
24
26
  const tunnels = tunnelService.listActiveTunnels();
25
27
  const autoSync = store.isAutoSyncEnabled ? store.isAutoSyncEnabled() : false;
26
28
  const alerts = alertService ? alertService.getAlerts() : [];
29
+ const authPrompts = sshService.listKeyboardPrompts ? sshService.listKeyboardPrompts() : [];
27
30
  const sanitized = profiles.map((p) => (store.vault ? store.vault.sanitizeProfile(p) : maskProfile(p)));
28
- writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts });
31
+ const terminalFontFamily = store.getTerminalFont ? store.getTerminalFont() : '';
32
+ writeJson(res, 200, { ok: true, activeId, profiles: sanitized, tunnels, autoSync, alerts, authPrompts, terminalFontFamily });
29
33
  } catch (err) {
30
34
  writeJson(res, 500, { ok: false, error: err.message });
31
35
  }
@@ -99,21 +103,91 @@ export function registerProfileRoutes(sctx, webServer, { store, sshService, aler
99
103
  if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
100
104
  if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
101
105
  try {
102
- const { content } = await readBody(req);
103
- if (!content || typeof content !== 'string') {
104
- return writeJson(res, 400, { ok: false, error: 'SSH config content is required' });
105
- }
106
- const importedProfiles = SshConfigParser.parse(content);
107
- for (const p of importedProfiles) {
106
+ const body = await readBody(req);
107
+ const content = typeof body.content === 'string' && body.content.trim()
108
+ ? body.content
109
+ : SshConfigParser.readDefaultConfig();
110
+ if (!content) return writeJson(res, 400, { ok: false, error: 'SSH config content is required' });
111
+ const report = SshConfigParser.parseReport(content, { baseDir: body.baseDir });
112
+ for (const p of report.profiles) {
108
113
  await store.saveProfile(p);
109
114
  }
110
- writeJson(res, 200, { ok: true, count: importedProfiles.length, profiles: importedProfiles });
115
+ writeJson(res, 200, {
116
+ ok: true,
117
+ count: report.profiles.length,
118
+ profiles: report.profiles,
119
+ skipped: report.skipped
120
+ });
111
121
  } catch (err) {
112
122
  writeJson(res, 500, { ok: false, error: err.message });
113
123
  }
114
124
  }
115
125
  }), 'dsh-remote-workspace: /profiles/import-ssh-config');
116
126
 
127
+
128
+ sctx.effect(() => webServer.register({
129
+ kind: 'exact',
130
+ path: '/dsh-remote-workspace/auth/keyboard',
131
+ handler: async (req, res) => {
132
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
133
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
134
+ try {
135
+ const body = await readBody(req);
136
+ const answers = Array.isArray(body.answers) ? body.answers.slice(0, 20).map((item) => String(item ?? '').slice(0, 256)) : [];
137
+ const accepted = sshService.submitKeyboardPrompt(body.id || '', answers);
138
+ if (!accepted) return writeJson(res, 404, { ok: false, error: 'No authentication prompt is waiting' });
139
+ writeJson(res, 200, { ok: true });
140
+ } catch (err) {
141
+ writeJson(res, 500, { ok: false, error: err.message });
142
+ }
143
+ }
144
+ }), 'dsh-remote-workspace: /auth/keyboard');
145
+
146
+
147
+ sctx.effect(() => webServer.register({
148
+ kind: 'exact',
149
+ path: '/dsh-remote-workspace/profiles/test-group',
150
+ handler: async (req, res) => {
151
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
152
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
153
+ try {
154
+ const body = await readBody(req);
155
+ const ids = Array.isArray(body.ids) ? body.ids.map((item) => String(item)) : [];
156
+ const selected = ids.map((id) => store.getProfile(id)).filter(Boolean);
157
+ const results = await testProfileGroup(selected, (profile) => sshService.testConnection(profile));
158
+ writeJson(res, 200, { ok: true, results });
159
+ } catch (err) {
160
+ writeJson(res, 500, { ok: false, error: err.message });
161
+ }
162
+ }
163
+ }), 'dsh-remote-workspace: /profiles/test-group');
164
+
165
+
166
+ sctx.effect(() => webServer.register({
167
+ kind: 'exact',
168
+ path: '/dsh-remote-workspace/cluster',
169
+ handler: async (req, res) => {
170
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' });
171
+ if (!isTrustedSettingsRequest(req)) return writeJson(res, 403, { ok: false, error: 'Forbidden' });
172
+ try {
173
+ const body = await readBody(req);
174
+ if (!body.command || typeof body.command !== 'string') return writeJson(res, 400, { ok: false, error: 'Command is required' });
175
+ const selected = filterClusterProfiles(store.getProfiles(), {
176
+ environment: body.environment,
177
+ tags: Array.isArray(body.tags) ? body.tags : [],
178
+ aliases: Array.isArray(body.aliases) ? body.aliases : []
179
+ });
180
+ const results = await runCluster(selected, body.command, {
181
+ maxWorkers: body.maxWorkers,
182
+ exec: (profile, command) => sshService.exec(profile, command, { timeout: 60, cleanAnsi: true })
183
+ });
184
+ writeJson(res, 200, { ok: true, results });
185
+ } catch (err) {
186
+ writeJson(res, 500, { ok: false, error: err.message });
187
+ }
188
+ }
189
+ }), 'dsh-remote-workspace: /cluster');
190
+
117
191
  // 6. POST /dsh-remote-workspace/test
118
192
  sctx.effect(() => webServer.register({
119
193
  kind: 'exact',