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.
- package/LICENSE +661 -0
- package/README.md +321 -295
- package/bin/ezpm2gui.js +10 -10
- package/bin/ezpm2gui.ts +51 -51
- package/bin/generate-ecosystem.js +36 -36
- package/bin/generate-ecosystem.ts +56 -56
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/server/config/project-configs.json +236 -0
- package/dist/server/index.js +214 -25
- package/dist/server/routes/deployApplication.js +6 -5
- package/dist/server/routes/pageAuth.d.ts +3 -0
- package/dist/server/routes/pageAuth.js +177 -0
- package/dist/server/routes/remoteConnections.js +260 -0
- package/dist/server/routes/updates.d.ts +3 -0
- package/dist/server/routes/updates.js +135 -0
- package/dist/server/utils/remote-connection.d.ts +18 -0
- package/dist/server/utils/remote-connection.js +216 -9
- package/package.json +73 -71
- package/scripts/postinstall.js +36 -36
- package/src/client/build/asset-manifest.json +6 -6
- package/src/client/build/favicon.ico +2 -2
- package/src/client/build/index.html +1 -1
- package/src/client/build/logo192.svg +7 -7
- package/src/client/build/logo512.svg +7 -7
- package/src/client/build/manifest.json +24 -24
- package/src/client/build/static/css/main.775772ee.css +5 -0
- package/src/client/build/static/css/main.775772ee.css.map +1 -0
- package/src/client/build/static/js/main.cbcb09c9.js +3 -0
- package/src/client/build/static/js/main.cbcb09c9.js.map +1 -0
- package/dist/server/config/cron-jobs.json +0 -1
- package/dist/server/config/remote-connections.json +0 -3
- package/dist/server/daemon/ezpm2gui.err.log +0 -414
- package/dist/server/daemon/ezpm2gui.exe +0 -0
- package/dist/server/daemon/ezpm2gui.exe.config +0 -6
- package/dist/server/daemon/ezpm2gui.out.log +0 -289
- package/dist/server/daemon/ezpm2gui.wrapper.log +0 -172
- package/dist/server/daemon/ezpm2gui.xml +0 -32
- package/src/client/build/static/css/main.c506cba5.css +0 -5
- package/src/client/build/static/css/main.c506cba5.css.map +0 -1
- package/src/client/build/static/js/main.5278cddd.js +0 -3
- package/src/client/build/static/js/main.5278cddd.js.map +0 -1
- /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,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
|