@woopsy/mcpanel 1.0.1 → 1.1.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/README.md CHANGED
@@ -144,6 +144,26 @@ npm run dev # run from TypeScript (ts-node)
144
144
  npm run build # compile to dist/
145
145
  ```
146
146
 
147
+ ### Releasing (maintainers)
148
+
149
+ Publishing is automated by GitHub Actions (`.github/workflows/publish.yml`). You never
150
+ run `npm publish` by hand.
151
+
152
+ **One-time setup:**
153
+ 1. Create an npm **Automation** token: npmjs.com → Access Tokens → Generate New Token →
154
+ *Automation* (these bypass 2FA in CI).
155
+ 2. Add it to the repo: GitHub → Settings → Secrets and variables → Actions → New repository
156
+ secret → name `NPM_TOKEN`.
157
+
158
+ **Every release after that:**
159
+ ```bash
160
+ npm version patch # or minor / major — bumps package.json and creates a git tag
161
+ git push --follow-tags # pushes the commit + tag; CI builds and publishes to npm
162
+ ```
163
+
164
+ The workflow skips automatically if that version is already on npm, and `npm install -g @woopsy/mcpanel@latest`
165
+ picks up the new version once it's green.
166
+
147
167
  ---
148
168
 
149
169
  ## License
@@ -42,6 +42,7 @@ const path = __importStar(require("path"));
42
42
  const colors = __importStar(require("../utils/colors"));
43
43
  const helpers_1 = require("../utils/helpers");
44
44
  const downloadService_1 = require("../services/downloadService");
45
+ const updateChecker_1 = require("../services/updateChecker");
45
46
  const pidusage_1 = __importDefault(require("pidusage"));
46
47
  class CommandRouter {
47
48
  configManager;
@@ -74,6 +75,7 @@ class CommandRouter {
74
75
  ' /properties - Edit server.properties interactively',
75
76
  '',
76
77
  colors.bold(colors.green('Tunnel Commands (Playit.gg)')),
78
+ ' /setup - One-time Playit account claim (browser approval)',
77
79
  ' /tunnel java - Auto-create & start a Java tunnel, returns address',
78
80
  ' /tunnel bedrock - Auto-create & start a Bedrock tunnel, returns address',
79
81
  ' /tunnel status - Check tunnel status, address and latency',
@@ -95,6 +97,7 @@ class CommandRouter {
95
97
  ' /java [path] - Show/list Java runtimes, or set the one used to launch',
96
98
  ' /folder - Open the server folder in the file explorer',
97
99
  ' /clear - Clear the screen, scrollback and command history',
100
+ ' /update - Check npm for a newer version of MCPANEL',
98
101
  ' /config - View active application config.json',
99
102
  ' /exit - Close MCPANEL server manager',
100
103
  colors.gray('──────────────────────────────────────────────\n')
@@ -464,6 +467,22 @@ class CommandRouter {
464
467
  this.configManager.updateSettings({ defaultJavaPath: cleanPath });
465
468
  return colors.success(`Java set to "${cleanPath}" (version ${info.version}). It will be used on the next /start.`);
466
469
  }
470
+ /**
471
+ * Executes /update — checks npm for a newer version and prints how to update.
472
+ */
473
+ async executeUpdate() {
474
+ const info = await (0, updateChecker_1.checkForUpdate)(true);
475
+ if (!info) {
476
+ return colors.warning('Could not check for updates (no network connection?).');
477
+ }
478
+ if (info.updateAvailable) {
479
+ return [
480
+ colors.warning(`Update available: ${colors.bold(info.current)} → ${colors.bold(colors.green(info.latest))}`),
481
+ colors.gray('Update with: ') + colors.cyan(`npm i -g ${info.name}@latest`),
482
+ ].join('\n');
483
+ }
484
+ return colors.success(`You're on the latest version (${info.current}).`);
485
+ }
467
486
  /**
468
487
  * Executes /config
469
488
  */
@@ -514,6 +533,36 @@ class CommandRouter {
514
533
  return colors.failure(`Failed to create tunnel: ${err.message}`);
515
534
  }
516
535
  }
536
+ /**
537
+ * Executes /setup — runs the one-time Playit agent claim (browser approval),
538
+ * saving the agent secret. Works on all platforms (HTTP claim, no playit-cli).
539
+ * After this, /tunnel java|bedrock is fully automatic.
540
+ */
541
+ async executeSetup() {
542
+ if (this.playitManager.getSecret()) {
543
+ return colors.warning('Playit is already set up (agent secret saved). Run /tunnel reset first if you want to re-claim.');
544
+ }
545
+ try {
546
+ await this.playitManager.ensureSecret({
547
+ onClaimUrl: (url) => {
548
+ const opened = (0, helpers_1.openInBrowser)(url);
549
+ console.log(`\n🔗 ${colors.bold('Approve the agent in your browser to finish setup.')}`);
550
+ if (opened) {
551
+ console.log(colors.gray('Your browser was opened automatically — sign in and click Approve.'));
552
+ }
553
+ else {
554
+ console.log(colors.gray('Open this link, sign in (free account), and click Approve:'));
555
+ }
556
+ console.log(colors.underline(colors.cyan(url)));
557
+ },
558
+ onStatus: (msg) => console.log(colors.info(msg)),
559
+ });
560
+ return colors.success('Playit is set up! You can now run /tunnel java or /tunnel bedrock.');
561
+ }
562
+ catch (err) {
563
+ return colors.failure(`Setup failed: ${err.message}`);
564
+ }
565
+ }
517
566
  /**
518
567
  * Executes /tunnel reset — clears the saved secret so the agent can be re-claimed.
519
568
  */
package/dist/index.js CHANGED
@@ -50,6 +50,7 @@ const playitManager_1 = require("./managers/playitManager");
50
50
  const commandRouter_1 = require("./commands/commandRouter");
51
51
  const colors = __importStar(require("./utils/colors"));
52
52
  const helpers_1 = require("./utils/helpers");
53
+ const updateChecker_1 = require("./services/updateChecker");
53
54
  const logger_1 = require("./utils/logger");
54
55
  // Initialize managers
55
56
  const configManager = new configManager_1.ConfigManager();
@@ -70,6 +71,12 @@ let consoleActiveServer = '';
70
71
  let logViewServer = '';
71
72
  // Readline interface
72
73
  let rl;
74
+ let CLI_VERSION = '1.0.3';
75
+ try {
76
+ const pkg = JSON.parse(fs.readFileSync(path.join(configManager_1.APP_ROOT, 'package.json'), 'utf-8'));
77
+ CLI_VERSION = pkg.version || '1.0.3';
78
+ }
79
+ catch { /* ignore */ }
73
80
  /**
74
81
  * Renders the figlet "MCPANEL" ASCII banner with a chalk gradient.
75
82
  */
@@ -79,7 +86,7 @@ function renderBanner() {
79
86
  const tints = [chalk_1.default.cyanBright, chalk_1.default.cyan, chalk_1.default.greenBright, chalk_1.default.green, chalk_1.default.green];
80
87
  console.log();
81
88
  lines.forEach((line, i) => console.log((tints[i] || chalk_1.default.green)(line)));
82
- console.log(chalk_1.default.greenBright.bold(' Minecraft Server Manager') + chalk_1.default.gray(' v1.0.0'));
89
+ console.log(chalk_1.default.greenBright.bold(' Minecraft Server Manager') + chalk_1.default.gray(` v${CLI_VERSION}`));
83
90
  }
84
91
  /**
85
92
  * Renders the neofetch / Arch-Linux-style info block for the synced server.
@@ -159,8 +166,9 @@ const COMMAND_LIST = [
159
166
  '/stats', '/folder', '/properties', '/java',
160
167
  '/backup create', '/backup list', '/backup restore',
161
168
  '/plugins list', '/plugins install', '/plugins remove',
169
+ '/setup',
162
170
  '/tunnel java', '/tunnel bedrock', '/tunnel status', '/tunnel stop', '/tunnel reset',
163
- '/config', '/clear', '/exit'
171
+ '/config', '/clear', '/update', '/exit'
164
172
  ];
165
173
  // Subcommands offered once "<command> " has been typed.
166
174
  const SUBCOMMANDS = {
@@ -608,6 +616,9 @@ async function handleCommandState(line) {
608
616
  case '/java':
609
617
  console.log(router.executeJava(args.length ? args.join(' ') : undefined));
610
618
  break;
619
+ case '/update':
620
+ console.log(await router.executeUpdate());
621
+ break;
611
622
  case '/config':
612
623
  console.log(router.executeConfig());
613
624
  break;
@@ -654,6 +665,9 @@ async function handleCommandState(line) {
654
665
  console.log(colors.failure('Syntax: /plugins [list|install|remove]'));
655
666
  }
656
667
  break;
668
+ case '/setup':
669
+ console.log(await router.executeSetup());
670
+ break;
657
671
  case '/tunnel': {
658
672
  const sub = (args[0] || '').toLowerCase();
659
673
  if (!sub) {
@@ -716,11 +730,29 @@ async function finishStartup() {
716
730
  currentState = 'COMMAND';
717
731
  promptUser();
718
732
  }
733
+ /**
734
+ * Prints a one-time-per-launch notice if a newer version is on npm. Fail-silent
735
+ * and cached, so it never slows down or blocks startup.
736
+ */
737
+ async function showUpdateNotice() {
738
+ try {
739
+ const info = await (0, updateChecker_1.checkForUpdate)();
740
+ if (info && info.updateAvailable) {
741
+ console.log();
742
+ console.log(chalk_1.default.yellow(' ⚡ Update available: ') + chalk_1.default.gray(info.current) + chalk_1.default.gray(' → ') + chalk_1.default.greenBright.bold(info.latest));
743
+ console.log(chalk_1.default.gray(' Update with: ') + chalk_1.default.cyan(`npm i -g ${info.name}@latest`));
744
+ }
745
+ }
746
+ catch {
747
+ // Never let an update check break startup.
748
+ }
749
+ }
719
750
  /**
720
751
  * Main application setup
721
752
  */
722
753
  async function main() {
723
754
  renderBanner();
755
+ await showUpdateNotice();
724
756
  rl = readline.createInterface({
725
757
  input: process.stdin,
726
758
  output: process.stdout,
@@ -37,6 +37,7 @@ exports.PlayitManager = void 0;
37
37
  const fs = __importStar(require("fs"));
38
38
  const path = __importStar(require("path"));
39
39
  const https = __importStar(require("https"));
40
+ const crypto = __importStar(require("crypto"));
40
41
  const child_process_1 = require("child_process");
41
42
  const configManager_1 = require("../config/configManager");
42
43
  const helpers_1 = require("../utils/helpers");
@@ -209,6 +210,41 @@ class PlayitManager {
209
210
  getRunData(secret) {
210
211
  return this.apiPost('/agents/rundata', {}, secret);
211
212
  }
213
+ /**
214
+ * Unauthenticated POST for the claim endpoints. Unlike apiPost it returns the
215
+ * raw { status, data } envelope and does NOT throw on `status:"fail"` — a fail
216
+ * (e.g. "CodeNotFound" while waiting for approval) is a normal polling state.
217
+ */
218
+ apiClaim(apiPath, body) {
219
+ const payload = JSON.stringify(body || {});
220
+ const url = new URL(PLAYIT_API + apiPath);
221
+ return new Promise((resolve, reject) => {
222
+ const req = https.request({
223
+ hostname: url.hostname,
224
+ path: url.pathname,
225
+ method: 'POST',
226
+ headers: {
227
+ 'Content-Type': 'application/json',
228
+ 'Content-Length': Buffer.byteLength(payload),
229
+ },
230
+ }, (res) => {
231
+ let data = '';
232
+ res.on('data', (c) => { data += c; });
233
+ res.on('end', () => {
234
+ try {
235
+ const parsed = JSON.parse(data);
236
+ resolve({ status: parsed.status, data: parsed.data });
237
+ }
238
+ catch {
239
+ reject(new Error(`Bad claim response: ${data.slice(0, 200)}`));
240
+ }
241
+ });
242
+ });
243
+ req.on('error', reject);
244
+ req.write(payload);
245
+ req.end();
246
+ });
247
+ }
212
248
  /** Picks the public address from a rundata tunnel entry. */
213
249
  tunnelAddress(tunnel) {
214
250
  const port = tunnel?.port?.from ?? tunnel?.local_port ?? 0;
@@ -262,81 +298,92 @@ class PlayitManager {
262
298
  });
263
299
  });
264
300
  }
265
- /** Ensures a write-capable agent secret, driving the one-time claim if needed. */
301
+ /**
302
+ * Ensures a write-capable agent secret, driving the one-time claim if needed.
303
+ *
304
+ * The claim is done entirely over the playit HTTP API (no playit-cli), so it
305
+ * works identically on Windows, WSL, Linux and macOS. The agent binary is only
306
+ * needed later, for the traffic relay.
307
+ */
266
308
  async ensureSecret(callbacks = {}) {
267
309
  const existing = this.getSecret();
268
310
  if (existing)
269
311
  return existing;
270
- await this.ensureBinary();
312
+ // A claim code is just a short random hex string generated client-side.
313
+ const code = crypto.randomBytes(5).toString('hex');
314
+ const claimBody = { code, agent_type: 'self-managed', version: 'mcpanel 1.0' };
271
315
  callbacks.onStatus?.('Generating a new agent claim code...');
272
- const code = (await this.runCli(['claim', 'generate'])).replace(/[^a-f0-9]/gi, '');
273
- if (!code)
274
- throw new Error('Failed to generate a claim code.');
275
- const url = (await this.runCli(['claim', 'url', code, '--name', 'mcpanel', '--type', 'self-managed'])).trim();
316
+ await this.apiClaim('/claim/setup', claimBody);
317
+ const url = `https://playit.gg/claim/${code}`;
276
318
  this.tunnelStatus.claimUrl = url;
277
319
  callbacks.onClaimUrl?.(url);
278
- callbacks.onStatus?.('Waiting for the agent to be claimed (this only happens once)...');
279
- const secret = await this.exchangeClaim(code);
320
+ // Poll setup (which also keeps the code alive) until the user approves.
321
+ callbacks.onStatus?.('Waiting for you to approve the agent in your browser (this only happens once)...');
322
+ const deadline = Date.now() + 5 * 60 * 1000; // 5 minutes
323
+ let approved = false;
324
+ while (Date.now() < deadline) {
325
+ await this.sleep(3000);
326
+ const res = await this.apiClaim('/claim/setup', claimBody);
327
+ const status = typeof res.data === 'string' ? res.data : '';
328
+ if (status === 'UserAccepted') {
329
+ approved = true;
330
+ break;
331
+ }
332
+ if (status === 'UserRejected') {
333
+ this.tunnelStatus.claimUrl = null;
334
+ throw new Error('Claim was rejected in the browser. Run /setup to try again.');
335
+ }
336
+ }
337
+ if (!approved) {
338
+ this.tunnelStatus.claimUrl = null;
339
+ throw new Error('Timed out waiting for approval. Open the link, click Approve, then run /setup again.');
340
+ }
341
+ // Exchange the approved code for the 64-char agent secret.
342
+ callbacks.onStatus?.('Approved! Retrieving your agent secret...');
343
+ let secret = '';
344
+ for (let i = 0; i < 10 && !secret; i++) {
345
+ const ex = await this.apiClaim('/claim/exchange', { code });
346
+ const match = JSON.stringify(ex.data ?? '').match(/[a-f0-9]{64}/i);
347
+ if (ex.status === 'success' && match)
348
+ secret = match[0].toLowerCase();
349
+ else
350
+ await this.sleep(2000);
351
+ }
352
+ if (!secret) {
353
+ this.tunnelStatus.claimUrl = null;
354
+ throw new Error('Could not retrieve the agent secret after approval. Run /setup to try again.');
355
+ }
280
356
  this.configManager.setPlayitSecret(secret);
281
357
  this.tunnelStatus.claimUrl = null;
282
358
  callbacks.onStatus?.('Agent claimed and linked. Secret saved — future tunnels are fully automatic.');
283
359
  return secret;
284
360
  }
285
- /** Spawns `claim exchange` and resolves once a 64-char hex secret is printed. */
286
- exchangeClaim(code) {
287
- return new Promise((resolve, reject) => {
288
- const cliPath = this.getCliPath();
289
- const child = (0, child_process_1.spawn)(cliPath, ['claim', 'exchange', code, '--wait', '0'], {
290
- cwd: path.dirname(cliPath),
291
- stdio: ['ignore', 'pipe', 'pipe'],
292
- });
293
- this.claimProcess = child;
294
- let buffer = '';
295
- const scan = (data) => {
296
- buffer += stripAnsi(data.toString());
297
- const match = buffer.match(/[a-f0-9]{64}/i);
298
- if (match) {
299
- this.claimProcess = null;
300
- try {
301
- child.kill();
302
- }
303
- catch { /* ignore */ }
304
- resolve(match[0].toLowerCase());
305
- }
306
- };
307
- child.stdout?.on('data', scan);
308
- child.stderr?.on('data', scan);
309
- child.on('close', (codeExit) => {
310
- if (this.claimProcess === child) {
311
- this.claimProcess = null;
312
- const match = buffer.match(/[a-f0-9]{64}/i);
313
- if (match)
314
- resolve(match[0].toLowerCase());
315
- else
316
- reject(new Error(`Claim was not completed (exit ${codeExit}). Visit the link, then try again.`));
317
- }
318
- });
319
- child.on('error', (err) => { this.claimProcess = null; reject(err); });
320
- });
321
- }
322
361
  // ---------------------------------------------------------------------------
323
362
  // Full automated setup
324
363
  // ---------------------------------------------------------------------------
325
364
  /**
326
- * One-call entry point: ensures binary + secret, creates the tunnel via the
327
- * API, starts the relay daemon, and returns the live tunnel status.
365
+ * One-call entry point: ensures binary + secret, starts the relay daemon (so
366
+ * the agent registers its version with playit), creates the tunnel via the
367
+ * API, and returns the live tunnel status.
368
+ *
369
+ * The relay MUST be started before creating a tunnel — playit rejects
370
+ * /tunnels/create with "AgentVersionTooOld" until a current agent has
371
+ * connected and reported its version.
328
372
  */
329
373
  async setupAndStart(type, callbacks = {}) {
330
374
  await this.ensureBinary();
331
375
  const secret = await this.ensureSecret(callbacks);
332
376
  this.tunnelStatus.status = 'Connecting';
333
377
  this.tunnelStatus.type = type;
378
+ // Start the relay first so the agent connects and registers its version.
379
+ callbacks.onStatus?.('Starting tunnel agent...');
380
+ await this.startAgent(secret);
334
381
  callbacks.onStatus?.('Checking your playit account for an existing tunnel...');
335
382
  let rd = await this.getRunData(secret);
336
383
  let tunnel = this.findTunnel(rd, type);
337
384
  if (!tunnel) {
338
385
  callbacks.onStatus?.(`Creating ${type} tunnel...`);
339
- await this.createApiTunnel(type, rd.agent_id, secret);
386
+ await this.createTunnelWithRetry(type, rd.agent_id, secret, callbacks);
340
387
  // Poll until the tunnel leaves "pending" and gets a public address.
341
388
  for (let i = 0; i < 15 && !tunnel; i++) {
342
389
  await this.sleep(3000);
@@ -351,11 +398,38 @@ class PlayitManager {
351
398
  this.tunnelStatus.address = address;
352
399
  this.tunnelStatus.port = port;
353
400
  this.configManager.updatePlayitTunnel({ tunnelAddress: address, tunnelPort: Number(port) });
354
- callbacks.onStatus?.('Starting tunnel relay...');
355
- await this.startAgent(secret);
356
401
  this.tunnelStatus.status = 'Online';
357
402
  return this.tunnelStatus;
358
403
  }
404
+ /**
405
+ * Creates a tunnel, retrying on "AgentVersionTooOld" — that error means the
406
+ * freshly-started agent hasn't finished registering its version yet, so we
407
+ * wait and retry (refreshing the agent_id) a few times.
408
+ */
409
+ async createTunnelWithRetry(type, agentId, secret, callbacks) {
410
+ let lastErr;
411
+ for (let attempt = 0; attempt < 8; attempt++) {
412
+ try {
413
+ await this.createApiTunnel(type, agentId, secret);
414
+ return;
415
+ }
416
+ catch (err) {
417
+ lastErr = err;
418
+ if (!/AgentVersionTooOld/i.test(err.message || ''))
419
+ throw err;
420
+ callbacks.onStatus?.('Waiting for the agent to finish registering with playit...');
421
+ await this.sleep(4000);
422
+ try {
423
+ const rd = await this.getRunData(secret);
424
+ if (rd?.agent_id)
425
+ agentId = rd.agent_id;
426
+ }
427
+ catch { /* keep previous agentId */ }
428
+ }
429
+ }
430
+ throw new Error(`${lastErr?.message || 'AgentVersionTooOld'} — the playit agent did not register in time. ` +
431
+ `Make sure the server can reach playit.gg, then try /tunnel again.`);
432
+ }
359
433
  /** Spawns the long-running daemon that relays tunnel traffic. */
360
434
  startAgent(secret) {
361
435
  return new Promise((resolve) => {
@@ -0,0 +1,140 @@
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
+ exports.isNewer = isNewer;
37
+ exports.checkForUpdate = checkForUpdate;
38
+ const https = __importStar(require("https"));
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ const configManager_1 = require("../config/configManager");
42
+ /**
43
+ * Lightweight "is there a newer version on npm?" checker.
44
+ *
45
+ * - Reads the installed name/version from the package's own package.json.
46
+ * - Asks the npm registry's dist-tags endpoint for the latest version.
47
+ * - Caches the result (logs/.update-check.json) so we only hit the network
48
+ * every few hours, keeping startup fast.
49
+ * - Fully fail-silent: no network / offline / parse error => returns null.
50
+ */
51
+ const CACHE_FILE = path.join(configManager_1.APP_ROOT, 'logs', '.update-check.json');
52
+ const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // re-check at most every 6h
53
+ const FETCH_TIMEOUT_MS = 2500;
54
+ function readPkg() {
55
+ try {
56
+ const pkg = JSON.parse(fs.readFileSync(path.join(configManager_1.APP_ROOT, 'package.json'), 'utf-8'));
57
+ if (pkg && pkg.name && pkg.version)
58
+ return { name: pkg.name, version: pkg.version };
59
+ }
60
+ catch { /* ignore */ }
61
+ return null;
62
+ }
63
+ function parseVer(v) {
64
+ // Strip any pre-release/build suffix, then split into numeric parts.
65
+ return v.split('-')[0].split('.').map((n) => parseInt(n, 10) || 0);
66
+ }
67
+ /** True if `latest` is a higher semver than `current`. */
68
+ function isNewer(latest, current) {
69
+ const a = parseVer(latest);
70
+ const b = parseVer(current);
71
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
72
+ const x = a[i] || 0;
73
+ const y = b[i] || 0;
74
+ if (x > y)
75
+ return true;
76
+ if (x < y)
77
+ return false;
78
+ }
79
+ return false;
80
+ }
81
+ function fetchLatest(name) {
82
+ // dist-tags is a tiny payload: {"latest":"1.2.3", ...}
83
+ const url = `https://registry.npmjs.org/-/package/${name.replace('/', '%2F')}/dist-tags`;
84
+ return new Promise((resolve) => {
85
+ const req = https.get(url, { timeout: FETCH_TIMEOUT_MS }, (res) => {
86
+ if (res.statusCode && res.statusCode >= 400) {
87
+ res.resume();
88
+ resolve(null);
89
+ return;
90
+ }
91
+ let data = '';
92
+ res.on('data', (c) => { data += c; });
93
+ res.on('end', () => {
94
+ try {
95
+ resolve(JSON.parse(data).latest || null);
96
+ }
97
+ catch {
98
+ resolve(null);
99
+ }
100
+ });
101
+ });
102
+ req.on('error', () => resolve(null));
103
+ req.on('timeout', () => { req.destroy(); resolve(null); });
104
+ });
105
+ }
106
+ function readCache() {
107
+ try {
108
+ const c = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8'));
109
+ if (c && typeof c.latest === 'string' && typeof c.checkedAt === 'number')
110
+ return c;
111
+ }
112
+ catch { /* ignore */ }
113
+ return null;
114
+ }
115
+ function writeCache(latest) {
116
+ try {
117
+ fs.writeFileSync(CACHE_FILE, JSON.stringify({ latest, checkedAt: Date.now() }), 'utf-8');
118
+ }
119
+ catch { /* ignore */ }
120
+ }
121
+ /**
122
+ * Returns update info, or null if it couldn't be determined.
123
+ * Pass force=true (e.g. for an explicit /update command) to bypass the cache.
124
+ */
125
+ async function checkForUpdate(force = false) {
126
+ const pkg = readPkg();
127
+ if (!pkg)
128
+ return null;
129
+ if (!force) {
130
+ const cached = readCache();
131
+ if (cached && Date.now() - cached.checkedAt < CHECK_INTERVAL_MS) {
132
+ return { name: pkg.name, current: pkg.version, latest: cached.latest, updateAvailable: isNewer(cached.latest, pkg.version) };
133
+ }
134
+ }
135
+ const latest = await fetchLatest(pkg.name);
136
+ if (!latest)
137
+ return null;
138
+ writeCache(latest);
139
+ return { name: pkg.name, current: pkg.version, latest, updateAvailable: isNewer(latest, pkg.version) };
140
+ }
@@ -42,9 +42,11 @@ exports.getDirSize = getDirSize;
42
42
  exports.checkJava = checkJava;
43
43
  exports.findInstalledJavas = findInstalledJavas;
44
44
  exports.getSystemStats = getSystemStats;
45
+ exports.checkForUpdates = checkForUpdates;
45
46
  const fs = __importStar(require("fs"));
46
47
  const os = __importStar(require("os"));
47
48
  const child_process_1 = require("child_process");
49
+ const https = __importStar(require("https"));
48
50
  /**
49
51
  * Detects the runtime OS environment: Windows, WSL, or Linux
50
52
  */
@@ -363,3 +365,66 @@ function getSystemStats() {
363
365
  uptimeSeconds: Math.floor(os.uptime()),
364
366
  };
365
367
  }
368
+ /**
369
+ * Checks npm registry for a newer version of the CLI package.
370
+ * Returns the latest version string if a newer version is available, or null otherwise.
371
+ */
372
+ function checkForUpdates(currentVersion) {
373
+ return new Promise((resolve) => {
374
+ const options = {
375
+ hostname: 'registry.npmjs.org',
376
+ path: '/@woopsy/mcpanel/latest',
377
+ method: 'GET',
378
+ timeout: 2000,
379
+ headers: {
380
+ 'User-Agent': 'mcpanel-cli',
381
+ },
382
+ };
383
+ const req = https.get(options, (res) => {
384
+ if (res.statusCode !== 200) {
385
+ resolve(null);
386
+ return;
387
+ }
388
+ let data = '';
389
+ res.on('data', (chunk) => { data += chunk; });
390
+ res.on('end', () => {
391
+ try {
392
+ const parsed = JSON.parse(data);
393
+ const latest = parsed.version;
394
+ if (latest && isNewerVersion(currentVersion, latest)) {
395
+ resolve(latest);
396
+ }
397
+ else {
398
+ resolve(null);
399
+ }
400
+ }
401
+ catch {
402
+ resolve(null);
403
+ }
404
+ });
405
+ });
406
+ req.on('timeout', () => {
407
+ req.destroy();
408
+ resolve(null);
409
+ });
410
+ req.on('error', () => {
411
+ resolve(null);
412
+ });
413
+ });
414
+ }
415
+ /**
416
+ * Basic semver comparison (a < b)
417
+ */
418
+ function isNewerVersion(current, latest) {
419
+ const cParts = current.split('.').map(Number);
420
+ const lParts = latest.split('.').map(Number);
421
+ for (let i = 0; i < 3; i++) {
422
+ const c = cParts[i] || 0;
423
+ const l = lParts[i] || 0;
424
+ if (l > c)
425
+ return true;
426
+ if (c > l)
427
+ return false;
428
+ }
429
+ return false;
430
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@woopsy/mcpanel",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "MCPANEL — a terminal-based, single-server Minecraft server manager with an Arch/neofetch-style UI, live logs, backups, plugins and Playit.gg tunnels.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -18,6 +18,7 @@
18
18
  "dev": "ts-node src/index.ts",
19
19
  "prod": "node dist/index.js",
20
20
  "prepublishOnly": "npm run build",
21
+ "push": "node scripts/push.js",
21
22
  "install:global": "npm run build && npm install -g ."
22
23
  },
23
24
  "keywords": [