ezpm2gui 1.4.0 → 1.6.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 (43) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +321 -295
  3. package/bin/ezpm2gui.js +10 -10
  4. package/bin/ezpm2gui.ts +51 -51
  5. package/bin/generate-ecosystem.js +36 -36
  6. package/bin/generate-ecosystem.ts +56 -56
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +2 -2
  9. package/dist/server/config/project-configs.json +236 -0
  10. package/dist/server/index.js +214 -25
  11. package/dist/server/routes/deployApplication.js +6 -5
  12. package/dist/server/routes/pageAuth.d.ts +3 -0
  13. package/dist/server/routes/pageAuth.js +177 -0
  14. package/dist/server/routes/remoteConnections.js +260 -0
  15. package/dist/server/routes/updates.d.ts +3 -0
  16. package/dist/server/routes/updates.js +135 -0
  17. package/dist/server/utils/remote-connection.d.ts +18 -0
  18. package/dist/server/utils/remote-connection.js +216 -9
  19. package/package.json +73 -71
  20. package/scripts/postinstall.js +36 -36
  21. package/src/client/build/asset-manifest.json +6 -6
  22. package/src/client/build/favicon.ico +2 -2
  23. package/src/client/build/index.html +1 -1
  24. package/src/client/build/logo192.svg +7 -7
  25. package/src/client/build/logo512.svg +7 -7
  26. package/src/client/build/manifest.json +24 -24
  27. package/src/client/build/static/css/main.775772ee.css +5 -0
  28. package/src/client/build/static/css/main.775772ee.css.map +1 -0
  29. package/src/client/build/static/js/main.cbcb09c9.js +3 -0
  30. package/src/client/build/static/js/main.cbcb09c9.js.map +1 -0
  31. package/dist/server/config/cron-jobs.json +0 -1
  32. package/dist/server/config/remote-connections.json +0 -3
  33. package/dist/server/daemon/ezpm2gui.err.log +0 -414
  34. package/dist/server/daemon/ezpm2gui.exe +0 -0
  35. package/dist/server/daemon/ezpm2gui.exe.config +0 -6
  36. package/dist/server/daemon/ezpm2gui.out.log +0 -289
  37. package/dist/server/daemon/ezpm2gui.wrapper.log +0 -172
  38. package/dist/server/daemon/ezpm2gui.xml +0 -32
  39. package/src/client/build/static/css/main.c506cba5.css +0 -5
  40. package/src/client/build/static/css/main.c506cba5.css.map +0 -1
  41. package/src/client/build/static/js/main.5278cddd.js +0 -3
  42. package/src/client/build/static/js/main.5278cddd.js.map +0 -1
  43. /package/src/client/build/static/js/{main.5278cddd.js.LICENSE.txt → main.cbcb09c9.js.LICENSE.txt} +0 -0
@@ -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;
@@ -0,0 +1,135 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ const express_1 = require("express");
7
+ const child_process_1 = require("child_process");
8
+ const path_1 = __importDefault(require("path"));
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const https_1 = __importDefault(require("https"));
11
+ // @group Utilities : Read the current installed version from package.json
12
+ const getCurrentVersion = () => {
13
+ try {
14
+ // Try the package root (works when run from source or dist)
15
+ const candidates = [
16
+ path_1.default.resolve(__dirname, '../../../package.json'),
17
+ path_1.default.resolve(__dirname, '../../package.json'),
18
+ path_1.default.resolve(process.cwd(), 'package.json'),
19
+ ];
20
+ for (const p of candidates) {
21
+ if (fs_1.default.existsSync(p)) {
22
+ const pkg = JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
23
+ if (pkg.name === 'ezpm2gui')
24
+ return pkg.version;
25
+ }
26
+ }
27
+ }
28
+ catch {
29
+ // fall through
30
+ }
31
+ return '0.0.0';
32
+ };
33
+ // @group Utilities : Fetch latest version info from npm registry (no external deps)
34
+ const fetchNpmLatest = () => new Promise((resolve, reject) => {
35
+ const req = https_1.default.get('https://registry.npmjs.org/ezpm2gui/latest', { headers: { Accept: 'application/json' } }, (res) => {
36
+ let data = '';
37
+ res.on('data', (chunk) => { data += chunk; });
38
+ res.on('end', () => {
39
+ var _a;
40
+ try {
41
+ const json = JSON.parse(data);
42
+ resolve({
43
+ version: json.version,
44
+ description: json.description,
45
+ publishedAt: (_a = json.time) === null || _a === void 0 ? void 0 : _a.modified,
46
+ });
47
+ }
48
+ catch {
49
+ reject(new Error('Failed to parse npm registry response'));
50
+ }
51
+ });
52
+ });
53
+ req.on('error', reject);
54
+ req.setTimeout(8000, () => { req.destroy(); reject(new Error('npm registry request timed out')); });
55
+ });
56
+ // @group Utilities : Compare semver strings — returns true if b is newer than a
57
+ const isNewer = (current, latest) => {
58
+ const parse = (v) => v.replace(/^v/, '').split('.').map(Number);
59
+ const [cMaj, cMin, cPat] = parse(current);
60
+ const [lMaj, lMin, lPat] = parse(latest);
61
+ if (lMaj !== cMaj)
62
+ return lMaj > cMaj;
63
+ if (lMin !== cMin)
64
+ return lMin > cMin;
65
+ return lPat > cPat;
66
+ };
67
+ const router = (0, express_1.Router)();
68
+ // @group CheckUpdate : GET /api/update/check — returns current vs latest npm version
69
+ router.get('/check', async (_req, res) => {
70
+ try {
71
+ const currentVersion = getCurrentVersion();
72
+ const { version: latestVersion, publishedAt } = await fetchNpmLatest();
73
+ const updateAvailable = isNewer(currentVersion, latestVersion);
74
+ const result = {
75
+ currentVersion,
76
+ latestVersion,
77
+ updateAvailable,
78
+ publishedAt,
79
+ };
80
+ res.json({ success: true, data: result });
81
+ }
82
+ catch (err) {
83
+ console.error('Update check failed:', err);
84
+ res.status(503).json({ success: false, error: err.message || 'Failed to reach npm registry' });
85
+ }
86
+ });
87
+ // @group InstallUpdate : POST /api/update/install — installs ezpm2gui@latest globally
88
+ // Streams progress lines as newline-delimited JSON (ndjson) so the client can read incrementally.
89
+ router.post('/install', (req, res) => {
90
+ res.setHeader('Content-Type', 'application/x-ndjson');
91
+ res.setHeader('Transfer-Encoding', 'chunked');
92
+ res.setHeader('Cache-Control', 'no-cache');
93
+ res.flushHeaders();
94
+ const send = (type, message) => {
95
+ res.write(JSON.stringify({ type, message }) + '\n');
96
+ };
97
+ send('log', 'Starting update — running npm install -g ezpm2gui@latest...');
98
+ // Use `npm` with execFile for safety — no shell injection possible
99
+ const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
100
+ const child = (0, child_process_1.spawn)(npmCmd, ['install', '-g', 'ezpm2gui@latest'], {
101
+ stdio: ['ignore', 'pipe', 'pipe'],
102
+ shell: false,
103
+ });
104
+ child.stdout.on('data', (chunk) => {
105
+ chunk.toString().split('\n').filter(Boolean).forEach((line) => send('log', line));
106
+ });
107
+ child.stderr.on('data', (chunk) => {
108
+ // npm writes progress to stderr — treat as log unless it's an actual error keyword
109
+ const text = chunk.toString();
110
+ const isError = /^npm (ERR|error)/i.test(text.trim());
111
+ text.split('\n').filter(Boolean).forEach((line) => send(isError ? 'error' : 'log', line));
112
+ });
113
+ child.on('close', (code) => {
114
+ if (code === 0) {
115
+ send('done', 'Update installed successfully. Reload the page to use the new frontend. Restart the server to apply backend changes.');
116
+ }
117
+ else {
118
+ send('fail', `npm exited with code ${code}. Update may have failed.`);
119
+ }
120
+ res.end();
121
+ });
122
+ child.on('error', (err) => {
123
+ send('fail', `Failed to run npm: ${err.message}`);
124
+ res.end();
125
+ });
126
+ });
127
+ // @group RestartServer : POST /api/update/restart — graceful server restart (relies on process manager to respawn)
128
+ router.post('/restart', (_req, res) => {
129
+ res.json({ success: true, message: 'Server will restart in 1 second.' });
130
+ setTimeout(() => {
131
+ console.log('[ezpm2gui] Graceful restart triggered via API.');
132
+ process.exit(0);
133
+ }, 1000);
134
+ });
135
+ exports.default = router;
@@ -54,6 +54,24 @@ export declare class RemoteConnection extends EventEmitter {
54
54
  * @param forceSudo Whether to force using sudo for this specific command
55
55
  */
56
56
  executeCommand(command: string, forceSudo?: boolean): Promise<CommandResult>;
57
+ /**
58
+ * Stream a remote file directly into an HTTP response without buffering.
59
+ *
60
+ * Strategy (tried in order):
61
+ * 1. SFTP createReadStream — best; handles large files, uses file-transfer protocol
62
+ * 2. exec `cat <file>` — current SSH user, direct pipe to response
63
+ * 3. exec `sudo -S cat` — password-based sudo (when password auth is configured)
64
+ * 4. exec `sudo cat` — NOPASSWD sudo (key-based auth where sudo needs no password)
65
+ */
66
+ private static readonly SHELL_UNSAFE;
67
+ private static validateRemotePath;
68
+ streamFileToResponse(remotePath: string, res: import('express').Response, fileName: string): Promise<void>;
69
+ /**
70
+ * Stream a remote .gz file to an HTTP response, decompressing it on the fly.
71
+ * Uses SFTP + local zlib.createGunzip() pipeline — no server-side buffering.
72
+ * Falls back to exec `zcat` if SFTP is unavailable.
73
+ */
74
+ streamGzFileToResponse(remotePath: string, res: import('express').Response, fileName: string): Promise<void>;
57
75
  /**
58
76
  * Check if PM2 is installed on the remote server
59
77
  * Uses multiple detection methods for better reliability