ezpm2gui 1.4.0 → 1.5.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.
Files changed (39) hide show
  1. package/README.md +295 -295
  2. package/bin/ezpm2gui.js +8 -8
  3. package/bin/ezpm2gui.ts +51 -51
  4. package/bin/generate-ecosystem.js +35 -35
  5. package/bin/generate-ecosystem.ts +56 -56
  6. package/dist/index.js +1 -1
  7. package/dist/server/config/project-configs.json +236 -0
  8. package/dist/server/index.js +212 -25
  9. package/dist/server/routes/deployApplication.js +6 -5
  10. package/dist/server/routes/remoteConnections.js +260 -0
  11. package/dist/server/routes/updates.d.ts +3 -0
  12. package/dist/server/routes/updates.js +135 -0
  13. package/dist/server/utils/remote-connection.d.ts +18 -0
  14. package/dist/server/utils/remote-connection.js +216 -9
  15. package/package.json +73 -71
  16. package/scripts/postinstall.js +36 -36
  17. package/src/client/build/asset-manifest.json +6 -6
  18. package/src/client/build/favicon.ico +2 -2
  19. package/src/client/build/index.html +1 -1
  20. package/src/client/build/logo192.svg +7 -7
  21. package/src/client/build/logo512.svg +7 -7
  22. package/src/client/build/manifest.json +24 -24
  23. package/src/client/build/static/css/main.2d095544.css +5 -0
  24. package/src/client/build/static/css/main.2d095544.css.map +1 -0
  25. package/src/client/build/static/js/main.17e17668.js +3 -0
  26. package/src/client/build/static/js/main.17e17668.js.map +1 -0
  27. package/dist/server/config/cron-jobs.json +0 -1
  28. package/dist/server/config/remote-connections.json +0 -3
  29. package/dist/server/daemon/ezpm2gui.err.log +0 -414
  30. package/dist/server/daemon/ezpm2gui.exe +0 -0
  31. package/dist/server/daemon/ezpm2gui.exe.config +0 -6
  32. package/dist/server/daemon/ezpm2gui.out.log +0 -289
  33. package/dist/server/daemon/ezpm2gui.wrapper.log +0 -172
  34. package/dist/server/daemon/ezpm2gui.xml +0 -32
  35. package/src/client/build/static/css/main.c506cba5.css +0 -5
  36. package/src/client/build/static/css/main.c506cba5.css.map +0 -1
  37. package/src/client/build/static/js/main.5278cddd.js +0 -3
  38. package/src/client/build/static/js/main.5278cddd.js.map +0 -1
  39. /package/src/client/build/static/js/{main.5278cddd.js.LICENSE.txt → main.17e17668.js.LICENSE.txt} +0 -0
@@ -4,10 +4,16 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.createServer = createServer;
7
+ const path_1 = __importDefault(require("path"));
8
+ // @group Configuration : Load .env.local first (local overrides), then .env (defaults)
9
+ // Must happen before any code reads process.env
10
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
11
+ const dotenv = require('dotenv');
12
+ dotenv.config({ path: path_1.default.resolve(process.cwd(), '.env.local') });
13
+ dotenv.config({ path: path_1.default.resolve(process.cwd(), '.env') });
7
14
  const express_1 = __importDefault(require("express"));
8
15
  const http_1 = __importDefault(require("http"));
9
16
  const socket_io_1 = require("socket.io");
10
- const path_1 = __importDefault(require("path"));
11
17
  const pm2_1 = __importDefault(require("pm2"));
12
18
  const os_1 = __importDefault(require("os"));
13
19
  const processConfig_1 = __importDefault(require("./routes/processConfig"));
@@ -15,6 +21,7 @@ const deployApplication_1 = __importDefault(require("./routes/deployApplication"
15
21
  const modules_1 = __importDefault(require("./routes/modules"));
16
22
  const remoteConnections_1 = __importDefault(require("./routes/remoteConnections"));
17
23
  const cronJobs_1 = __importDefault(require("./routes/cronJobs"));
24
+ const updates_1 = __importDefault(require("./routes/updates"));
18
25
  const logStreaming_1 = require("./routes/logStreaming");
19
26
  const pm2_connection_1 = require("./utils/pm2-connection");
20
27
  const remote_connection_1 = require("./utils/remote-connection");
@@ -58,6 +65,7 @@ function createServer() {
58
65
  app.use('/api/modules', modules_1.default);
59
66
  app.use('/api/remote', remoteConnections_1.default);
60
67
  app.use('/api/cron-jobs', cronJobs_1.default);
68
+ app.use('/api/update', updates_1.default);
61
69
  // Setup log streaming with Socket.IO
62
70
  (0, logStreaming_1.setupLogStreaming)(io); // PM2 API endpoints
63
71
  app.get('/api/processes', async (req, res) => {
@@ -127,45 +135,224 @@ function createServer() {
127
135
  };
128
136
  res.json(metrics);
129
137
  });
130
- // Get process logs
138
+ // @group LogHistory : Resolve log path from PM2 process descriptor
139
+ const resolveLocalLogPath = async (id, logType) => {
140
+ var _a, _b, _c;
141
+ const processDesc = await (0, pm2_connection_1.executePM2Command)((callback) => {
142
+ pm2_1.default.describe(id, callback);
143
+ });
144
+ if (!processDesc || processDesc.length === 0)
145
+ return null;
146
+ return (_c = (_b = (_a = processDesc[0]) === null || _a === void 0 ? void 0 : _a.pm2_env) === null || _b === void 0 ? void 0 : _b[`pm_${logType}_log_path`]) !== null && _c !== void 0 ? _c : null;
147
+ };
148
+ // @group LogHistory : Get log lines — ?lines=N (default 200, 0 = all)
131
149
  app.get('/api/logs/:id/:type', async (req, res) => {
132
- var _a, _b;
133
150
  const { id, type } = req.params;
134
151
  const logType = type === 'err' ? 'err' : 'out';
152
+ const lines = parseInt(req.query.lines || '200', 10);
135
153
  try {
136
- const processDesc = await (0, pm2_connection_1.executePM2Command)((callback) => {
137
- pm2_1.default.describe(id, callback);
138
- });
139
- if (!processDesc || processDesc.length === 0) {
140
- res.status(404).json({ error: 'Process not found' });
154
+ const logPath = await resolveLocalLogPath(id, logType);
155
+ if (!logPath) {
156
+ res.status(404).json({ error: 'Process not found or log path unavailable' });
157
+ return;
158
+ }
159
+ const fs = require('fs');
160
+ if (!fs.existsSync(logPath)) {
161
+ res.json({ logs: [], logPath });
141
162
  return;
142
163
  }
143
- const logPath = (_b = (_a = processDesc[0]) === null || _a === void 0 ? void 0 : _a.pm2_env) === null || _b === void 0 ? void 0 : _b[`pm_${logType}_log_path`];
164
+ const { lines: result, total } = await streamTailLines(fs.createReadStream(logPath), lines);
165
+ res.json({ logs: result, logPath, totalLines: total });
166
+ }
167
+ catch (err) {
168
+ console.error(`Error reading log file: ${err}`);
169
+ res.status(500).json({ error: 'Failed to read log file' });
170
+ }
171
+ });
172
+ // @group LogHistory : Download full log file
173
+ app.get('/api/logs/:id/:type/download', async (req, res) => {
174
+ const { id, type } = req.params;
175
+ const logType = type === 'err' ? 'err' : 'out';
176
+ try {
177
+ const logPath = await resolveLocalLogPath(id, logType);
144
178
  if (!logPath) {
145
- res.status(404).json({ error: `Log file for ${logType} not found` });
179
+ res.status(404).json({ error: 'Process not found or log path unavailable' });
146
180
  return;
147
181
  }
148
182
  const fs = require('fs');
149
- let logContent = '';
150
- if (fs.existsSync(logPath)) {
151
- const stats = fs.statSync(logPath);
152
- const fileSize = stats.size;
153
- const readSize = Math.min(fileSize, 10 * 1024); // 10KB max
154
- const position = Math.max(0, fileSize - readSize);
155
- const buffer = Buffer.alloc(readSize);
156
- const fd = fs.openSync(logPath, 'r');
157
- fs.readSync(fd, buffer, 0, readSize, position);
158
- fs.closeSync(fd);
159
- logContent = buffer.toString('utf8');
183
+ if (!fs.existsSync(logPath)) {
184
+ res.status(404).json({ error: 'Log file does not exist yet' });
185
+ return;
160
186
  }
161
- const logs = logContent.split('\n').filter((line) => line.trim() !== '');
162
- res.json({ logs });
187
+ const fileName = `${id}-${logType}.log`;
188
+ res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`);
189
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
190
+ fs.createReadStream(logPath).pipe(res);
163
191
  }
164
192
  catch (err) {
165
- console.error(`Error reading log file: ${err}`);
193
+ console.error(`Error downloading log file: ${err}`);
194
+ res.status(500).json({ error: 'Failed to download log file' });
195
+ }
196
+ });
197
+ // @group LogHistory : List all log files (current + rotated) for a process
198
+ // Uses /api/log-files/:id to avoid Express matching /:id/:type with type='files'
199
+ app.get('/api/log-files/:id', async (req, res) => {
200
+ const { id } = req.params;
201
+ try {
202
+ const fs = require('fs');
203
+ const nodePath = require('path');
204
+ const [outPath, errPath] = await Promise.all([
205
+ resolveLocalLogPath(id, 'out'),
206
+ resolveLocalLogPath(id, 'err'),
207
+ ]);
208
+ if (!outPath && !errPath) {
209
+ res.status(404).json({ error: 'Process not found or no log paths available' });
210
+ return;
211
+ }
212
+ // Derive the process base name from the log path (strip -out.log suffix)
213
+ const baseName = outPath
214
+ ? nodePath.basename(outPath).replace(/-out\.log.*$/, '')
215
+ : nodePath.basename(errPath).replace(/-(error|err)\.log.*$/, '');
216
+ const logDirs = new Set();
217
+ if (outPath)
218
+ logDirs.add(nodePath.dirname(outPath));
219
+ if (errPath)
220
+ logDirs.add(nodePath.dirname(errPath));
221
+ const files = [];
222
+ for (const dir of logDirs) {
223
+ if (!fs.existsSync(dir))
224
+ continue;
225
+ for (const fileName of fs.readdirSync(dir)) {
226
+ if (!fileName.startsWith(baseName))
227
+ continue;
228
+ const filePath = nodePath.join(dir, fileName);
229
+ const stat = fs.statSync(filePath);
230
+ if (!stat.isFile())
231
+ continue;
232
+ let type = 'unknown';
233
+ if (fileName.includes('-out'))
234
+ type = 'out';
235
+ else if (fileName.includes('-error') || fileName.includes('-err'))
236
+ type = 'err';
237
+ files.push({
238
+ name: fileName,
239
+ path: filePath,
240
+ size: stat.size,
241
+ modified: stat.mtime.toISOString(),
242
+ type,
243
+ compressed: fileName.endsWith('.gz'),
244
+ });
245
+ }
246
+ }
247
+ files.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
248
+ res.json({ files });
249
+ }
250
+ catch (err) {
251
+ console.error('Error listing log files:', err);
252
+ res.status(500).json({ error: 'Failed to list log files' });
253
+ }
254
+ });
255
+ // @group LogHistory : Security helper — block path traversal; allow any absolute log file path
256
+ const isAllowedLogPath = (filePath) => {
257
+ const nodePath = require('path');
258
+ const norm = nodePath.normalize(filePath);
259
+ // Require absolute path; reject shell-dangerous characters; require log extension
260
+ const SHELL_UNSAFE = /['"`;$|&<>(){}\\\n\r\0]/;
261
+ return (nodePath.isAbsolute(norm) &&
262
+ !norm.includes('..') &&
263
+ !SHELL_UNSAFE.test(norm) &&
264
+ /\.(log|gz)$/i.test(norm));
265
+ };
266
+ // @group LogHistory : Stream last N lines from a readable stream (ring buffer, no full-file load)
267
+ const streamTailLines = (inputStream, maxLines) => {
268
+ return new Promise((resolve, reject) => {
269
+ const rl = require('readline').createInterface({ input: inputStream, crlfDelay: Infinity });
270
+ const buffer = [];
271
+ let total = 0;
272
+ rl.on('line', (line) => {
273
+ if (line.trim() === '')
274
+ return;
275
+ total++;
276
+ if (maxLines > 0) {
277
+ buffer.push(line);
278
+ if (buffer.length > maxLines)
279
+ buffer.shift();
280
+ }
281
+ else {
282
+ buffer.push(line);
283
+ }
284
+ });
285
+ rl.on('close', () => resolve({ lines: buffer, total }));
286
+ rl.on('error', reject);
287
+ inputStream.on('error', reject);
288
+ });
289
+ };
290
+ // @group LogHistory : Read a specific log file by path — ?lines=N, supports .gz
291
+ // Uses /api/log-file (singular, top-level) to avoid clashing with /api/logs/:id/:type
292
+ app.get('/api/log-file', async (req, res) => {
293
+ const filePath = req.query.path;
294
+ const lines = parseInt(req.query.lines || '200', 10);
295
+ if (!filePath) {
296
+ res.status(400).json({ error: 'path query parameter required' });
297
+ return;
298
+ }
299
+ if (!isAllowedLogPath(filePath)) {
300
+ res.status(403).json({ error: 'Access denied: path is outside PM2 log directories' });
301
+ return;
302
+ }
303
+ try {
304
+ const fs = require('fs');
305
+ const zlib = require('zlib');
306
+ if (!fs.existsSync(filePath)) {
307
+ res.status(404).json({ error: 'File not found' });
308
+ return;
309
+ }
310
+ const inputStream = filePath.endsWith('.gz')
311
+ ? fs.createReadStream(filePath).pipe(zlib.createGunzip())
312
+ : fs.createReadStream(filePath);
313
+ const { lines: result, total } = await streamTailLines(inputStream, lines);
314
+ res.json({ logs: result, totalLines: total });
315
+ }
316
+ catch (err) {
317
+ console.error('Error reading log file:', err);
166
318
  res.status(500).json({ error: 'Failed to read log file' });
167
319
  }
168
320
  });
321
+ // @group LogHistory : Download a specific log file by path (streams .gz as-is)
322
+ app.get('/api/log-file/download', async (req, res) => {
323
+ const filePath = req.query.path;
324
+ if (!filePath) {
325
+ res.status(400).json({ error: 'path query parameter required' });
326
+ return;
327
+ }
328
+ if (!isAllowedLogPath(filePath)) {
329
+ res.status(403).json({ error: 'Access denied' });
330
+ return;
331
+ }
332
+ try {
333
+ const fs = require('fs');
334
+ const zlib = require('zlib');
335
+ const nodePath = require('path');
336
+ if (!fs.existsSync(filePath)) {
337
+ res.status(404).json({ error: 'File not found' });
338
+ return;
339
+ }
340
+ // Decompress .gz server-side so the download is always plain text
341
+ const baseName = nodePath.basename(filePath).replace(/\.gz$/i, '');
342
+ res.setHeader('Content-Disposition', `attachment; filename="${baseName}"`);
343
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
344
+ if (filePath.endsWith('.gz')) {
345
+ fs.createReadStream(filePath).pipe(zlib.createGunzip()).pipe(res);
346
+ }
347
+ else {
348
+ fs.createReadStream(filePath).pipe(res);
349
+ }
350
+ }
351
+ catch (err) {
352
+ console.error('Error downloading log file:', err);
353
+ res.status(500).json({ error: 'Failed to download file' });
354
+ }
355
+ });
169
356
  // WebSocket for real-time updates
170
357
  io.on('connection', (socket) => {
171
358
  console.log('Client connected');
@@ -219,7 +406,7 @@ function createServer() {
219
406
  }
220
407
  // Only start the server if this file is run directly
221
408
  if (require.main === module) {
222
- const PORT = process.env.PORT || 3001;
409
+ const PORT = process.env.PORT || 3101;
223
410
  const HOST = process.env.HOST || 'localhost';
224
411
  const server = createServer();
225
412
  server.listen(PORT, () => {
@@ -11,7 +11,7 @@ const ProjectSetupService_1 = require("../services/ProjectSetupService");
11
11
  const router = (0, express_1.Router)();
12
12
  // Deploy a new application
13
13
  router.post('/', async (req, res) => {
14
- const { name, script, cwd, instances, exec_mode, autorestart, watch, max_memory_restart, env, appType, autoSetup = true } = req.body;
14
+ const { name, script, cwd, namespace, instances, exec_mode, autorestart, watch, max_memory_restart, env, appType, autoSetup = true } = req.body;
15
15
  // Validate required fields
16
16
  if (!name || !script) {
17
17
  return res.status(400).json({ error: 'Name and script path are required' });
@@ -67,6 +67,7 @@ router.post('/', async (req, res) => {
67
67
  name,
68
68
  script,
69
69
  cwd: projectPath,
70
+ namespace: namespace || 'default',
70
71
  instances: parseInt(instances) || 1,
71
72
  exec_mode: exec_mode || 'fork',
72
73
  autorestart: autorestart !== undefined ? autorestart : true,
@@ -158,8 +159,8 @@ router.post('/generate-ecosystem', (req, res) => {
158
159
  env: pm2Env.env || {}
159
160
  };
160
161
  });
161
- const ecosystemConfig = `module.exports = {
162
- apps: ${JSON.stringify(apps, null, 2)}
162
+ const ecosystemConfig = `module.exports = {
163
+ apps: ${JSON.stringify(apps, null, 2)}
163
164
  };`;
164
165
  // Create the file (either at specified path or default location)
165
166
  const filePath = req.body.path || path_1.default.join(process.cwd(), 'ecosystem.config.js');
@@ -212,8 +213,8 @@ router.get('/generate-ecosystem-preview', (req, res) => {
212
213
  env: pm2Env.env || {}
213
214
  };
214
215
  });
215
- const ecosystemConfig = `module.exports = {
216
- apps: ${JSON.stringify(apps, null, 2)}
216
+ const ecosystemConfig = `module.exports = {
217
+ apps: ${JSON.stringify(apps, null, 2)}
217
218
  };`;
218
219
  pm2_1.default.disconnect();
219
220
  res.json({
@@ -6,6 +6,26 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const express_1 = __importDefault(require("express"));
7
7
  const remote_connection_1 = require("../utils/remote-connection");
8
8
  const router = express_1.default.Router();
9
+ // @group Security : Validate remote log file paths before shell interpolation
10
+ const SHELL_UNSAFE_CHARS = /['"`;$|&<>(){}\\\n\r\0]/;
11
+ const MAX_LOG_LINES = 10000;
12
+ const validateRemotePath = (filePath) => {
13
+ if (!filePath)
14
+ return false;
15
+ if (filePath.includes('..'))
16
+ return false;
17
+ if (SHELL_UNSAFE_CHARS.test(filePath))
18
+ return false;
19
+ if (!/\.(log|gz)$/i.test(filePath))
20
+ return false;
21
+ return true;
22
+ };
23
+ const safeLogLines = (raw) => {
24
+ const n = parseInt(raw || '200', 10);
25
+ if (!Number.isFinite(n) || n < 0)
26
+ return 200;
27
+ return Math.min(n, MAX_LOG_LINES);
28
+ };
9
29
  /**
10
30
  * Connect to an existing remote server
11
31
  * POST /api/remote/:connectionId/connect
@@ -552,6 +572,246 @@ router.get('/:connectionId/logs/:processId', async (req, res) => {
552
572
  });
553
573
  }
554
574
  });
575
+ // @group LogHistory : Helper — resolve log path for a given process on a remote connection
576
+ const resolveRemoteLogPath = async (connection, processId, logType) => {
577
+ var _a, _b;
578
+ const processInfoResult = await connection.executePM2Command('jlist');
579
+ if (processInfoResult.code !== 0)
580
+ return { logPath: null, error: 'Failed to get PM2 process list' };
581
+ try {
582
+ let raw = processInfoResult.stdout.trim();
583
+ const start = raw.indexOf('[');
584
+ const end = raw.lastIndexOf(']') + 1;
585
+ if (start !== -1 && end > 0)
586
+ raw = raw.substring(start, end);
587
+ const processList = JSON.parse(raw);
588
+ const proc = processList.find((p) => p.pm_id === parseInt(processId, 10) || p.name === processId);
589
+ if (!proc)
590
+ return { logPath: null, error: 'Process not found' };
591
+ const key = logType === 'out' ? 'pm_out_log_path' : 'pm_err_log_path';
592
+ return { logPath: (_b = (_a = proc.pm2_env) === null || _a === void 0 ? void 0 : _a[key]) !== null && _b !== void 0 ? _b : null };
593
+ }
594
+ catch {
595
+ return { logPath: null, error: 'Failed to parse PM2 process list' };
596
+ }
597
+ };
598
+ /**
599
+ * Get log lines from a specific log type on a remote process — ?lines=N (default 200, 0 = all)
600
+ * GET /api/remote/:connectionId/logs/:processId/:type
601
+ */
602
+ router.get('/:connectionId/logs/:processId/:type', async (req, res) => {
603
+ var _a;
604
+ try {
605
+ const { connectionId, processId, type } = req.params;
606
+ const logType = type === 'err' ? 'err' : 'out';
607
+ const lines = safeLogLines(req.query.lines);
608
+ const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
609
+ if (!connection)
610
+ return res.status(404).json({ success: false, error: 'Connection not found' });
611
+ if (!connection.isConnected())
612
+ return res.status(400).json({ success: false, error: 'Not connected' });
613
+ const { logPath, error } = await resolveRemoteLogPath(connection, processId, logType);
614
+ if (!logPath)
615
+ return res.status(404).json({ success: false, error: error || 'Log path not found' });
616
+ // tail -n 0 = all lines; use wc -l to get total count alongside
617
+ const lineArg = lines === 0 ? '+1' : `-${lines}`;
618
+ const cmd = `{ wc -l < "${logPath}" 2>/dev/null || echo 0; } && tail -n ${lineArg} "${logPath}" 2>/dev/null`;
619
+ let result = await connection.executeCommand(cmd);
620
+ // Fallback to sudo if the file is unreadable (root-owned logs)
621
+ if (result.code !== 0 || !result.stdout.trim()) {
622
+ result = await connection.executeCommand(`{ sudo wc -l < "${logPath}" 2>/dev/null || echo 0; } && sudo tail -n ${lineArg} "${logPath}" 2>/dev/null`);
623
+ }
624
+ const outputLines = result.stdout.split('\n');
625
+ const totalLines = parseInt(((_a = outputLines[0]) === null || _a === void 0 ? void 0 : _a.trim()) || '0', 10);
626
+ const logLines = outputLines.slice(1).filter((l) => l.trim() !== '');
627
+ res.json({ logs: logLines, logPath, totalLines });
628
+ }
629
+ catch (error) {
630
+ console.error('Error fetching remote log history:', error);
631
+ res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
632
+ }
633
+ });
634
+ /**
635
+ * Download full log file from a remote process via SSH
636
+ * GET /api/remote/:connectionId/logs/:processId/:type/download
637
+ */
638
+ router.get('/:connectionId/logs/:processId/:type/download', async (req, res) => {
639
+ try {
640
+ const { connectionId, processId, type } = req.params;
641
+ const logType = type === 'err' ? 'err' : 'out';
642
+ const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
643
+ if (!connection)
644
+ return res.status(404).json({ success: false, error: 'Connection not found' });
645
+ if (!connection.isConnected())
646
+ return res.status(400).json({ success: false, error: 'Not connected' });
647
+ const { logPath, error } = await resolveRemoteLogPath(connection, processId, logType);
648
+ if (!logPath)
649
+ return res.status(404).json({ success: false, error: error || 'Log path not found' });
650
+ const fileName = `${processId}-${logType}.log`;
651
+ await connection.streamFileToResponse(logPath, res, fileName);
652
+ }
653
+ catch (error) {
654
+ console.error('Error downloading remote log:', error);
655
+ if (!res.headersSent) {
656
+ res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
657
+ }
658
+ }
659
+ });
660
+ /**
661
+ * List all log files (current + rotated) for a remote process
662
+ * GET /api/remote/:connectionId/log-files/:processId
663
+ * Uses /log-files/ prefix to avoid Express matching /:processId/:type with type='files'
664
+ */
665
+ router.get('/:connectionId/log-files/:processId', async (req, res) => {
666
+ try {
667
+ const { connectionId, processId } = req.params;
668
+ const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
669
+ if (!connection)
670
+ return res.status(404).json({ success: false, error: 'Connection not found' });
671
+ if (!connection.isConnected())
672
+ return res.status(400).json({ success: false, error: 'Not connected' });
673
+ // Resolve both log paths so we know the directory and base name
674
+ const { logPath: outPath } = await resolveRemoteLogPath(connection, processId, 'out');
675
+ const { logPath: errPath } = await resolveRemoteLogPath(connection, processId, 'err');
676
+ if (!outPath && !errPath) {
677
+ return res.status(404).json({ success: false, error: 'No log paths found for this process' });
678
+ }
679
+ // Derive log directory + base name from the out log path (or err if out missing)
680
+ const refPath = outPath || errPath;
681
+ const logDir = refPath.substring(0, refPath.lastIndexOf('/'));
682
+ const baseName = refPath.split('/').pop().replace(/-out\.log.*$/, '').replace(/-(error|err)\.log.*$/, '');
683
+ // List matching files in the log directory
684
+ const lsCmd = `ls -la "${logDir}" 2>/dev/null`;
685
+ let lsResult = await connection.executeCommand(lsCmd);
686
+ if (lsResult.code !== 0)
687
+ lsResult = await connection.executeCommand(`sudo ${lsCmd}`);
688
+ const files = [];
689
+ if (lsResult.code === 0) {
690
+ for (const line of lsResult.stdout.split('\n')) {
691
+ // ls -la line: permissions links owner group size month day time name
692
+ const parts = line.trim().split(/\s+/);
693
+ if (parts.length < 9)
694
+ continue;
695
+ const fileName = parts.slice(8).join(' ');
696
+ if (!fileName.startsWith(baseName))
697
+ continue;
698
+ const size = parseInt(parts[4], 10) || 0;
699
+ const month = parts[5];
700
+ const day = parts[6];
701
+ const timeOrYr = parts[7];
702
+ const modified = `${month} ${day} ${timeOrYr}`;
703
+ let type = 'unknown';
704
+ if (fileName.includes('-out'))
705
+ type = 'out';
706
+ else if (fileName.includes('-error') || fileName.includes('-err'))
707
+ type = 'err';
708
+ files.push({
709
+ name: fileName,
710
+ path: `${logDir}/${fileName}`,
711
+ size,
712
+ modified,
713
+ type,
714
+ compressed: fileName.endsWith('.gz'),
715
+ });
716
+ }
717
+ }
718
+ // Sort: current files first (no date suffix), then rotated newest first
719
+ files.sort((a, b) => {
720
+ const aRot = a.name.includes('__') || /\d{4}-\d{2}/.test(a.name);
721
+ const bRot = b.name.includes('__') || /\d{4}-\d{2}/.test(b.name);
722
+ if (!aRot && bRot)
723
+ return -1;
724
+ if (aRot && !bRot)
725
+ return 1;
726
+ return b.name.localeCompare(a.name);
727
+ });
728
+ res.json({ files });
729
+ }
730
+ catch (error) {
731
+ console.error('Error listing remote log files:', error);
732
+ res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
733
+ }
734
+ });
735
+ /**
736
+ * Read a specific remote log file by path — ?lines=N, handles .gz via zcat
737
+ * GET /api/remote/:connectionId/log-file?path=...&lines=N
738
+ * Uses /log-file top-level to avoid clashing with /logs/:processId routes
739
+ */
740
+ router.get('/:connectionId/log-file', async (req, res) => {
741
+ var _a;
742
+ try {
743
+ const { connectionId } = req.params;
744
+ const filePath = req.query.path;
745
+ const lines = safeLogLines(req.query.lines);
746
+ if (!filePath)
747
+ return res.status(400).json({ error: 'path query parameter required' });
748
+ // Strict path validation — blocks traversal, shell metacharacters, and non-log extensions
749
+ if (!validateRemotePath(filePath)) {
750
+ return res.status(403).json({ error: 'Access denied: invalid or unsafe log file path' });
751
+ }
752
+ const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
753
+ if (!connection)
754
+ return res.status(404).json({ success: false, error: 'Connection not found' });
755
+ if (!connection.isConnected())
756
+ return res.status(400).json({ success: false, error: 'Not connected' });
757
+ const isGz = filePath.endsWith('.gz');
758
+ const catCmd = isGz ? `zcat "${filePath}" 2>/dev/null` : `cat "${filePath}" 2>/dev/null`;
759
+ const lineArg = lines === 0 ? '' : `| tail -n ${lines}`;
760
+ const countCmd = isGz
761
+ ? `zcat "${filePath}" 2>/dev/null | wc -l`
762
+ : `wc -l < "${filePath}" 2>/dev/null`;
763
+ const [contentResult, countResult] = await Promise.all([
764
+ connection.executeCommand(`${catCmd} ${lineArg}`).catch(() => ({ code: 1, stdout: '', stderr: '' })),
765
+ connection.executeCommand(countCmd).catch(() => ({ code: 1, stdout: '0', stderr: '' })),
766
+ ]);
767
+ // Fallback to sudo on permission error
768
+ const content = contentResult.code === 0 && contentResult.stdout.trim()
769
+ ? contentResult
770
+ : await connection.executeCommand(`sudo ${catCmd} ${lineArg}`);
771
+ const totalLines = parseInt(((_a = countResult.stdout) === null || _a === void 0 ? void 0 : _a.trim()) || '0', 10);
772
+ const logLines = (content.stdout || '').split('\n').filter((l) => l.trim() !== '');
773
+ res.json({ logs: logLines, totalLines });
774
+ }
775
+ catch (error) {
776
+ console.error('Error reading remote log file:', error);
777
+ res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
778
+ }
779
+ });
780
+ /**
781
+ * Download a specific remote log file by path via SSH cat/zcat
782
+ * GET /api/remote/:connectionId/log-file/download?path=...
783
+ */
784
+ router.get('/:connectionId/log-file/download', async (req, res) => {
785
+ try {
786
+ const { connectionId } = req.params;
787
+ const filePath = req.query.path;
788
+ if (!filePath)
789
+ return res.status(400).json({ error: 'path query parameter required' });
790
+ if (!validateRemotePath(filePath)) {
791
+ return res.status(403).json({ error: 'Access denied: invalid or unsafe log file path' });
792
+ }
793
+ const connection = remote_connection_1.remoteConnectionManager.getConnection(connectionId);
794
+ if (!connection)
795
+ return res.status(404).json({ success: false, error: 'Connection not found' });
796
+ if (!connection.isConnected())
797
+ return res.status(400).json({ success: false, error: 'Not connected' });
798
+ // .gz files: stream via SFTP + local gunzip (no server-side memory buffering)
799
+ if (filePath.endsWith('.gz')) {
800
+ const gzName = filePath.split('/').pop().replace(/\.gz$/, '');
801
+ await connection.streamGzFileToResponse(filePath, res, gzName);
802
+ return;
803
+ }
804
+ // Plain files: SFTP stream
805
+ const fileName = filePath.split('/').pop();
806
+ await connection.streamFileToResponse(filePath, res, fileName);
807
+ }
808
+ catch (error) {
809
+ console.error('Error downloading remote log file:', error);
810
+ if (!res.headersSent) {
811
+ res.status(500).json({ success: false, error: `Server error: ${error instanceof Error ? error.message : 'Unknown error'}` });
812
+ }
813
+ }
814
+ });
555
815
  /**
556
816
  * Get list of all remote connections with their status
557
817
  * GET /api/remote/connections
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ declare const router: Router;
3
+ export default router;