easy-vps 0.1.4 → 0.1.6

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/client/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>easy-vps</title>
8
- <script type="module" crossorigin src="/assets/index-D8fZ27vP.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Dq-W19hL.css">
8
+ <script type="module" crossorigin src="/assets/index-Sc-92iW0.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-DAQUecJE.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export * as database from './services/database';
19
19
  export * as instances from './services/instances';
20
20
  export * as logs from './services/logs';
21
21
  export * as firewall from './services/firewall';
22
+ export * as github from './services/github';
22
23
  export * as backupScheduler from './services/backup-scheduler';
23
24
  export * as installer from './services/installer';
24
25
  export { runInstaller } from './services/installer';
package/dist/index.js CHANGED
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.runInstaller = exports.installer = exports.backupScheduler = exports.firewall = exports.logs = exports.instances = exports.database = exports.deploy = exports.domain = exports.system = exports.auth = exports.DEFAULT_HOST = exports.DEFAULT_PORT = void 0;
39
+ exports.runInstaller = exports.installer = exports.backupScheduler = exports.github = exports.firewall = exports.logs = exports.instances = exports.database = exports.deploy = exports.domain = exports.system = exports.auth = exports.DEFAULT_HOST = exports.DEFAULT_PORT = void 0;
40
40
  exports.clientDir = clientDir;
41
41
  exports.createApp = createApp;
42
42
  exports.startServer = startServer;
@@ -50,6 +50,7 @@ const deploy_1 = __importDefault(require("./routes/deploy"));
50
50
  const domain_1 = __importDefault(require("./routes/domain"));
51
51
  const instances_1 = __importDefault(require("./routes/instances"));
52
52
  const firewall_1 = __importDefault(require("./routes/firewall"));
53
+ const github_1 = __importDefault(require("./routes/github"));
53
54
  const logs_1 = __importDefault(require("./routes/logs"));
54
55
  const system_1 = __importDefault(require("./routes/system"));
55
56
  const auth = __importStar(require("./services/auth"));
@@ -90,6 +91,7 @@ function createApp() {
90
91
  app.use('/api/databases', database_1.default);
91
92
  app.use('/api/instances', instances_1.default);
92
93
  app.use('/api/firewall', firewall_1.default);
94
+ app.use('/api/github', github_1.default);
93
95
  app.use('/api/logs', logs_1.default);
94
96
  app.use('/api', (_req, res) => {
95
97
  res.status(404).json({ error: 'Not found' });
@@ -143,6 +145,7 @@ exports.database = __importStar(require("./services/database"));
143
145
  exports.instances = __importStar(require("./services/instances"));
144
146
  exports.logs = __importStar(require("./services/logs"));
145
147
  exports.firewall = __importStar(require("./services/firewall"));
148
+ exports.github = __importStar(require("./services/github"));
146
149
  exports.backupScheduler = __importStar(require("./services/backup-scheduler"));
147
150
  exports.installer = __importStar(require("./services/installer"));
148
151
  var installer_1 = require("./services/installer");
@@ -0,0 +1,2 @@
1
+ declare const router: import("express-serve-static-core").Router;
2
+ export default router;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const express_1 = require("express");
37
+ const github = __importStar(require("../services/github"));
38
+ const async_route_1 = require("./async-route");
39
+ const router = (0, express_1.Router)();
40
+ router.get('/config', (0, async_route_1.asyncRoute)(async (req, res) => {
41
+ res.json(await github.getGithubConfig(req.remote));
42
+ }));
43
+ router.put('/config', (0, async_route_1.asyncRoute)(async (req, res) => {
44
+ const patch = req.body;
45
+ if (!patch || typeof patch !== 'object') {
46
+ res.status(400).json({ error: 'Request body must be a config object.' });
47
+ return;
48
+ }
49
+ try {
50
+ const config = await github.updateGithubConfig(req.remote, patch);
51
+ res.json(config);
52
+ }
53
+ catch (cause) {
54
+ res.status(400).json({ error: cause.message });
55
+ }
56
+ }));
57
+ exports.default = router;
@@ -59,6 +59,23 @@ router.get('/metrics', (0, async_route_1.asyncRoute)(async (req, res) => {
59
59
  router.get('/metrics/history', (0, async_route_1.asyncRoute)(async (req, res) => {
60
60
  res.json((0, metrics_history_1.getLoadHistory)(req.remote.host));
61
61
  }));
62
+ router.get('/config', (0, async_route_1.asyncRoute)(async (req, res) => {
63
+ res.json(await system.getSystemConfig(req.remote));
64
+ }));
65
+ router.put('/config', (0, async_route_1.asyncRoute)(async (req, res) => {
66
+ const patch = req.body;
67
+ if (!patch || typeof patch !== 'object') {
68
+ res.status(400).json({ error: 'Request body must be a config object.' });
69
+ return;
70
+ }
71
+ try {
72
+ const config = await system.updateSystemConfig(req.remote, patch);
73
+ res.json(config);
74
+ }
75
+ catch (cause) {
76
+ res.status(400).json({ error: cause.message });
77
+ }
78
+ }));
62
79
  router.get('/update/check', (0, async_route_1.asyncRoute)(async (_req, res) => {
63
80
  res.json(await update.checkForUpdate());
64
81
  }));
@@ -0,0 +1,23 @@
1
+ import { type Remote } from './ssh';
2
+ export interface GithubConfig {
3
+ username: string;
4
+ email: string;
5
+ /** The stored personal access token; returned so the UI can prefill it. */
6
+ personalAccessToken: string;
7
+ /** True when a token is already stored in the credential store on the server. */
8
+ tokenSet: boolean;
9
+ }
10
+ /** Only the fields the user may change; each is optional on update. */
11
+ export interface GithubConfigPatch {
12
+ username?: string;
13
+ email?: string;
14
+ personalAccessToken?: string;
15
+ }
16
+ /** Reads the git identity currently configured for the remote user. */
17
+ export declare function getGithubConfig(remote: Remote): Promise<GithubConfig>;
18
+ /**
19
+ * Applies the supplied github identity over SSH and stores the personal access
20
+ * token in git's credential store so HTTPS clones can authenticate. Returns the
21
+ * resulting config (without the token, which is never sent back).
22
+ */
23
+ export declare function updateGithubConfig(remote: Remote, patch: GithubConfigPatch): Promise<GithubConfig>;
@@ -0,0 +1,68 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getGithubConfig = getGithubConfig;
4
+ exports.updateGithubConfig = updateGithubConfig;
5
+ // Github identity and credential configuration on the managed VPS, over SSH.
6
+ const ssh_1 = require("./ssh");
7
+ function safeTrim(value) {
8
+ return typeof value === 'string' ? value.trim() : '';
9
+ }
10
+ /** Reads the git identity currently configured for the remote user. */
11
+ async function getGithubConfig(remote) {
12
+ const nameResult = await remote.exec((0, ssh_1.loginShell)('git config --global --get user.name 2>/dev/null'), 10_000);
13
+ const emailResult = await remote.exec((0, ssh_1.loginShell)('git config --global --get user.email 2>/dev/null'), 10_000);
14
+ const tokenResult = await remote.exec((0, ssh_1.loginShell)("cred=$(grep 'github.com' ~/.git-credentials 2>/dev/null | head -n1); " +
15
+ "if [ -n \"$cred\" ]; then " +
16
+ "printf '%s' \"$cred\" | sed -E 's#^https://([^@]+)@github.com/?$#\\1#' | sed -E 's#^[^:]+:##'; " +
17
+ "fi"), 10_000);
18
+ const token = tokenResult.stdout.trim();
19
+ return {
20
+ username: nameResult.stdout.trim(),
21
+ email: emailResult.stdout.trim(),
22
+ personalAccessToken: token,
23
+ tokenSet: token !== '',
24
+ };
25
+ }
26
+ /**
27
+ * Applies the supplied github identity over SSH and stores the personal access
28
+ * token in git's credential store so HTTPS clones can authenticate. Returns the
29
+ * resulting config (without the token, which is never sent back).
30
+ */
31
+ async function updateGithubConfig(remote, patch) {
32
+ const username = safeTrim(patch.username);
33
+ const email = safeTrim(patch.email);
34
+ const token = safeTrim(patch.personalAccessToken);
35
+ if (patch.username !== undefined && username === '') {
36
+ throw new Error('Github username is required.');
37
+ }
38
+ if (patch.email !== undefined) {
39
+ if (email === '')
40
+ throw new Error('Email is required.');
41
+ if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
42
+ throw new Error('Enter a valid email address.');
43
+ }
44
+ }
45
+ if (patch.personalAccessToken !== undefined && token === '') {
46
+ throw new Error('Personal access token is required.');
47
+ }
48
+ const commands = [];
49
+ if (patch.username !== undefined) {
50
+ commands.push(`git config --global user.name ${(0, ssh_1.quote)(username)}`);
51
+ }
52
+ if (patch.email !== undefined) {
53
+ commands.push(`git config --global user.email ${(0, ssh_1.quote)(email)}`);
54
+ }
55
+ commands.push('git config --global credential.helper store');
56
+ if (patch.personalAccessToken !== undefined) {
57
+ const url = `https://${username}:${token}@github.com`;
58
+ commands.push(`sed -i '/github.com/d' ~/.git-credentials 2>/dev/null || true; ` +
59
+ `printf '%s\\n' ${(0, ssh_1.quote)(url)} >> ~/.git-credentials; ` +
60
+ `chmod 600 ~/.git-credentials`);
61
+ }
62
+ const script = (0, ssh_1.loginShell)(commands.join('\n'));
63
+ const { code, stderr } = await remote.exec(script, 30_000);
64
+ if (code !== 0) {
65
+ throw new Error(`Failed to apply github configuration: ${stderr.trim()}`);
66
+ }
67
+ return getGithubConfig(remote);
68
+ }
@@ -27,6 +27,8 @@ export declare function quote(value: string): string;
27
27
  * because that runs a non-interactive, non-login shell.
28
28
  */
29
29
  export declare function loginShell(command: string): string;
30
+ /** `sudo -n` fails loudly rather than hanging on a password prompt; say so usefully. */
31
+ export declare function sudoFailure(stderr: string, username: string): string | null;
30
32
  /**
31
33
  * One managed VPS, bound to a logged-in session. Holds a single SSH connection
32
34
  * and reopens it transparently if the server drops it while the session is idle.
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Remote = exports.RemoteError = void 0;
4
4
  exports.quote = quote;
5
5
  exports.loginShell = loginShell;
6
+ exports.sudoFailure = sudoFailure;
6
7
  exports.createSession = createSession;
7
8
  exports.getSession = getSession;
8
9
  exports.destroySession = destroySession;
@@ -60,8 +60,45 @@ export interface SystemMetrics {
60
60
  network: NetworkInfo;
61
61
  topProcesses: TopProcess[];
62
62
  }
63
+ export interface SystemSwap {
64
+ totalMb: number;
65
+ usedMb: number;
66
+ freeMb: number;
67
+ }
68
+ /** Read-only system facts plus the few values the panel is allowed to change. */
69
+ export interface SystemConfig {
70
+ hostname: string;
71
+ timezone: string;
72
+ swapSwappiness: number;
73
+ os: string;
74
+ kernel: string;
75
+ architecture: string;
76
+ swap: SystemSwap;
77
+ memoryTotalMb: number;
78
+ /** The swap file the panel manages, or null when none is present. */
79
+ swapFile: {
80
+ path: string;
81
+ sizeMb: number;
82
+ } | null;
83
+ }
84
+ /** Only the fields the user is allowed to change; each is optional on update. */
85
+ export interface SystemConfigPatch {
86
+ hostname?: string;
87
+ timezone?: string;
88
+ swapSwappiness?: number;
89
+ /** Target swap file size in MB. 0 removes the managed swap file. */
90
+ swapFileSizeMb?: number;
91
+ }
63
92
  /** Collect comprehensive system metrics from the VPS. */
64
93
  export declare function getMetrics(remote: Remote): Promise<SystemMetrics>;
94
+ /**
95
+ * Gathers system configuration: a mix of read-only facts (OS, kernel, swap
96
+ * totals) and the small set of values the panel can change (hostname, timezone,
97
+ * swappiness).
98
+ */
99
+ export declare function getSystemConfig(remote: Remote): Promise<SystemConfig>;
100
+ /** Validates and applies the supplied config changes over SSH. Returns the new config. */
101
+ export declare function updateSystemConfig(remote: Remote, patch: SystemConfigPatch): Promise<SystemConfig>;
65
102
  /** Detect whether a dependency is present on the VPS. */
66
103
  export declare function detect(remote: Remote, name: DependencyName): Promise<DependencyStatus>;
67
104
  export declare function detectAll(remote: Remote): Promise<DependencyStatus[]>;
@@ -2,6 +2,8 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.PACKAGES = void 0;
4
4
  exports.getMetrics = getMetrics;
5
+ exports.getSystemConfig = getSystemConfig;
6
+ exports.updateSystemConfig = updateSystemConfig;
5
7
  exports.detect = detect;
6
8
  exports.detectAll = detectAll;
7
9
  exports.canAdminister = canAdminister;
@@ -174,6 +176,151 @@ function getDefaultMetrics() {
174
176
  topProcesses: [],
175
177
  };
176
178
  }
179
+ /**
180
+ * Gathers system configuration: a mix of read-only facts (OS, kernel, swap
181
+ * totals) and the small set of values the panel can change (hostname, timezone,
182
+ * swappiness).
183
+ */
184
+ async function getSystemConfig(remote) {
185
+ const configScript = `#!/bin/bash
186
+ host=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo "")
187
+ tz=$(cat /etc/timezone 2>/dev/null || timedatectl show --property=Timezone 2>/dev/null | cut -d= -f2 || echo "")
188
+ swappiness=$(cat /proc/sys/vm/swappiness 2>/dev/null || echo 0)
189
+ os=$(. /etc/os-release 2>/dev/null; echo "\${PRETTY_NAME:-Unknown}")
190
+ kernel=$(uname -r 2>/dev/null || echo "Unknown")
191
+ arch=$(uname -m 2>/dev/null || echo "Unknown")
192
+ swap_info=$(free -b 2>/dev/null | grep Swap)
193
+ swap_total=$(echo "$swap_info" | awk '{print $2}')
194
+ swap_used=$(echo "$swap_info" | awk '{print $3}')
195
+ swap_free=$(echo "$swap_info" | awk '{print $4}')
196
+ mem_total=$(free -b 2>/dev/null | grep Mem | awk '{print $2}')
197
+ # First file-backed swap (the panel only manages file swaps, not partitions).
198
+ swapfile_line=$(awk 'NR>1 && $2=="file" {print $1, $3; exit}' /proc/swaps 2>/dev/null)
199
+ swapfile_path=$(echo "$swapfile_line" | awk '{print $1}')
200
+ swapfile_kib=$(echo "$swapfile_line" | awk '{print $2}')
201
+ echo "HOST:$host"
202
+ echo "TZ:$tz"
203
+ echo "SWAPPINESS:$swappiness"
204
+ echo "OS:$os"
205
+ echo "KERNEL:$kernel"
206
+ echo "ARCH:$arch"
207
+ echo "SWAP_TOTAL:$swap_total"
208
+ echo "SWAP_USED:$swap_used"
209
+ echo "SWAP_FREE:$swap_free"
210
+ echo "MEM_TOTAL:$mem_total"
211
+ echo "SWAPFILE_PATH:$swapfile_path"
212
+ echo "SWAPFILE_KIB:$swapfile_kib"
213
+ `;
214
+ const { stdout, code } = await remote.exec((0, ssh_1.loginShell)(configScript), 15_000);
215
+ if (code !== 0) {
216
+ return {
217
+ hostname: '',
218
+ timezone: '',
219
+ swapSwappiness: 0,
220
+ os: 'Unknown',
221
+ kernel: 'Unknown',
222
+ architecture: 'Unknown',
223
+ swap: { totalMb: 0, usedMb: 0, freeMb: 0 },
224
+ memoryTotalMb: 0,
225
+ swapFile: null,
226
+ };
227
+ }
228
+ const lines = stdout.split('\n').filter((l) => l.includes(':'));
229
+ const get = (key) => {
230
+ const line = lines.find((l) => l.startsWith(`${key}:`));
231
+ return line ? line.slice(key.length + 1).trim() : '';
232
+ };
233
+ const swapTotal = parseInt(get('SWAP_TOTAL')) || 0;
234
+ const swapUsed = parseInt(get('SWAP_USED')) || 0;
235
+ const swapFree = parseInt(get('SWAP_FREE')) || 0;
236
+ const memTotal = parseInt(get('MEM_TOTAL')) || 0;
237
+ const swapfileKib = parseInt(get('SWAPFILE_KIB')) || 0;
238
+ const swapfilePath = get('SWAPFILE_PATH');
239
+ return {
240
+ hostname: get('HOST'),
241
+ timezone: get('TZ'),
242
+ swapSwappiness: Math.min(100, Math.max(0, parseInt(get('SWAPPINESS')) || 0)),
243
+ os: get('OS') || 'Unknown',
244
+ kernel: get('KERNEL') || 'Unknown',
245
+ architecture: get('ARCH') || 'Unknown',
246
+ swap: {
247
+ totalMb: parseBytesToMb(swapTotal),
248
+ usedMb: parseBytesToMb(swapUsed),
249
+ freeMb: parseBytesToMb(swapFree),
250
+ },
251
+ memoryTotalMb: parseBytesToMb(memTotal),
252
+ swapFile: swapfilePath && swapfileKib > 0
253
+ ? { path: swapfilePath, sizeMb: Math.round(swapfileKib / 1024) }
254
+ : null,
255
+ };
256
+ }
257
+ /** Validates and applies the supplied config changes over SSH. Returns the new config. */
258
+ async function updateSystemConfig(remote, patch) {
259
+ const commands = [];
260
+ if (patch.hostname !== undefined) {
261
+ const hostname = patch.hostname.trim();
262
+ if (!/^[a-zA-Z0-9.-]+$/.test(hostname)) {
263
+ throw new Error('Hostname may only contain letters, digits, dots and hyphens.');
264
+ }
265
+ commands.push(`hostnamectl set-hostname ${(0, ssh_1.quote)(hostname)} || printf %s ${(0, ssh_1.quote)(hostname)} > /etc/hostname`);
266
+ }
267
+ if (patch.timezone !== undefined) {
268
+ const tz = patch.timezone.trim();
269
+ if (!tz)
270
+ throw new Error('Timezone is required.');
271
+ commands.push(`timedatectl set-timezone ${(0, ssh_1.quote)(tz)} || ln -sf ${(0, ssh_1.quote)(`/usr/share/zoneinfo/${tz}`)} /etc/localtime`);
272
+ }
273
+ if (patch.swapSwappiness !== undefined) {
274
+ const n = patch.swapSwappiness;
275
+ if (!Number.isInteger(n) || n < 0 || n > 100) {
276
+ throw new Error('Swap swappiness must be an integer between 0 and 100.');
277
+ }
278
+ commands.push(`sysctl -w vm.swappiness=${n}`);
279
+ // Persist: replace the line if present, otherwise append.
280
+ commands.push(`mkdir -p /etc/sysctl.d; ` +
281
+ `{ grep -q '^vm.swappiness' /etc/sysctl.d/99-easyvps.conf && ` +
282
+ `sed -i 's/^vm.swappiness.*/vm.swappiness=${n}/' /etc/sysctl.d/99-easyvps.conf; } ` +
283
+ `|| echo 'vm.swappiness=${n}' >> /etc/sysctl.d/99-easyvps.conf`);
284
+ }
285
+ if (patch.swapFileSizeMb !== undefined) {
286
+ const mb = patch.swapFileSizeMb;
287
+ if (!Number.isInteger(mb) || mb < 0) {
288
+ throw new Error('Swap file size must be a non-negative integer (MB).');
289
+ }
290
+ const swapPath = '/swapfile';
291
+ // Full takeover: disable every active swap (file or partition), drop the
292
+ // managed file and its fstab entry, and comment out any other swap fstab
293
+ // lines so the new /swapfile is the only swap, now and after reboot.
294
+ const disableAll = `for d in $(awk 'NR>1 {print $1}' /proc/swaps 2>/dev/null); do swapoff "$d" 2>/dev/null || true; done; ` +
295
+ `rm -f ${(0, ssh_1.quote)(swapPath)}; ` +
296
+ `sed -i '\\#${swapPath}#d' /etc/fstab 2>/dev/null || true; ` +
297
+ `sed -i 's/^\\([^#].*[[:space:]]swap[[:space:]].*\\)$/#\\1/' /etc/fstab 2>/dev/null || true`;
298
+ if (mb === 0) {
299
+ commands.push(disableAll);
300
+ }
301
+ else {
302
+ commands.push(disableAll +
303
+ `; fallocate -l ${mb}M ${(0, ssh_1.quote)(swapPath)} 2>/dev/null || ` +
304
+ `dd if=/dev/zero of=${(0, ssh_1.quote)(swapPath)} bs=1M count=${mb} status=none; ` +
305
+ `chmod 600 ${(0, ssh_1.quote)(swapPath)}; ` +
306
+ `mkswap ${(0, ssh_1.quote)(swapPath)}; ` +
307
+ `swapon ${(0, ssh_1.quote)(swapPath)}; ` +
308
+ `echo ${(0, ssh_1.quote)(`${swapPath} none swap sw 0 0`)} >> /etc/fstab`);
309
+ }
310
+ }
311
+ if (commands.length === 0)
312
+ return getSystemConfig(remote);
313
+ if (!(await canAdminister(remote))) {
314
+ throw new Error(`"${remote.username}" cannot run privileged commands on ${remote.host}. ` +
315
+ 'Log in as root, or enable passwordless sudo.');
316
+ }
317
+ const script = (0, ssh_1.loginShell)(commands.join('\n'));
318
+ const { code, stderr } = await remote.exec(await remote.privileged(script), 30_000);
319
+ if (code !== 0) {
320
+ throw new Error((0, ssh_1.sudoFailure)(stderr, remote.username) ?? `Failed to apply configuration: ${stderr.trim()}`);
321
+ }
322
+ return getSystemConfig(remote);
323
+ }
177
324
  const DETECT_TIMEOUT_MS = 10_000;
178
325
  // A non-interactive shell misses tools installed outside the system prefix.
179
326
  // System paths stay first so a distro binary wins; every installed nvm version
@@ -44,6 +44,25 @@ function compareVersions(a, b) {
44
44
  }
45
45
  return 0;
46
46
  }
47
+ /**
48
+ * Locate the npm CLI. The panel often runs as a systemd service whose `PATH`
49
+ * omits npm (especially under nvm), so `spawn('npm')` fails with ENOENT. npm
50
+ * always sits next to the Node binary, so derive it from `process.execPath`.
51
+ */
52
+ function resolveNpmCli() {
53
+ const nodeBin = node_path_1.default.dirname(process.execPath);
54
+ const candidates = [
55
+ node_path_1.default.join(nodeBin, 'npm-cli.js'),
56
+ node_path_1.default.join(nodeBin, 'npm'),
57
+ '/usr/local/bin/npm',
58
+ '/usr/bin/npm',
59
+ ];
60
+ for (const candidate of candidates) {
61
+ if ((0, node_fs_1.existsSync)(candidate))
62
+ return candidate;
63
+ }
64
+ return null;
65
+ }
47
66
  /** Fetch the latest published version; falls back to the current one offline. */
48
67
  async function checkForUpdate() {
49
68
  const { name, version } = readPackage();
@@ -75,7 +94,15 @@ function runUpdate(handlers) {
75
94
  // `--ignore-scripts` skips the package's postinstall, which would otherwise
76
95
  // try to start the panel itself and race with the running one. We restart
77
96
  // deliberately once the install finishes.
78
- const proc = (0, node_child_process_1.spawn)('npm', ['install', '-g', `${name}@latest`, '--ignore-scripts', '--no-audit', '--no-fund'], { stdio: ['ignore', 'pipe', 'pipe'] });
97
+ const npmCli = resolveNpmCli();
98
+ const installArgs = ['install', '-g', `${name}@latest`, '--ignore-scripts', '--no-audit', '--no-fund'];
99
+ // Run npm through Node directly so a missing `npm` on PATH doesn't matter.
100
+ const proc = npmCli
101
+ ? (0, node_child_process_1.spawn)(process.execPath, [npmCli, ...installArgs], {
102
+ stdio: ['ignore', 'pipe', 'pipe'],
103
+ env: { ...process.env, PATH: `${node_path_1.default.dirname(process.execPath)}:${process.env.PATH ?? ''}` },
104
+ })
105
+ : (0, node_child_process_1.spawn)('npm', installArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
79
106
  const onData = (chunk) => {
80
107
  for (const line of chunk.toString().split('\n')) {
81
108
  if (line.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "easy-vps",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Install dependencies, point domains, deploy apps and run Postgres on a VPS from one UI",
5
5
  "keywords": [
6
6
  "vps",