@lvnt/release-radar 1.0.1 → 1.1.1

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/.env.example CHANGED
@@ -1,2 +1,4 @@
1
1
  TELEGRAM_BOT_TOKEN=your_bot_token_here
2
2
  TELEGRAM_CHAT_ID=your_chat_id_here
3
+ GITHUB_WEBHOOK_SECRET=your_webhook_secret_here
4
+ UPDATER_PORT=9000
package/README.md CHANGED
@@ -140,6 +140,33 @@ Edit `config/tools.json` to add/remove tools:
140
140
  | `/interval` | Show current check interval |
141
141
  | `/setinterval <hours>` | Set check interval (1-24 hours) |
142
142
 
143
+ ## Auto-Updater (Optional)
144
+
145
+ ReleaseRadar includes an optional auto-updater that receives GitHub webhooks and automatically updates itself when you publish a new version.
146
+
147
+ ### Setup
148
+
149
+ 1. Add to your `.env`:
150
+ ```env
151
+ GITHUB_WEBHOOK_SECRET=your_secret_here
152
+ UPDATER_PORT=9000
153
+ ```
154
+
155
+ 2. Configure GitHub webhook:
156
+ - Go to your repo's Settings → Webhooks → Add webhook
157
+ - Payload URL: `https://your-domain.com/webhook`
158
+ - Content type: `application/json`
159
+ - Secret: same as `GITHUB_WEBHOOK_SECRET`
160
+ - Events: Select "Releases" only
161
+
162
+ 3. Start the updater with pm2:
163
+ ```bash
164
+ pm2 start release-radar-updater --name release-radar-updater
165
+ pm2 save
166
+ ```
167
+
168
+ When you publish a new release, the updater will automatically run `npm update -g @lvnt/release-radar` and restart the main service.
169
+
143
170
  ## Project Structure
144
171
 
145
172
  ```
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { existsSync } from 'fs';
6
+ import { config } from 'dotenv';
7
+ import TelegramBot from 'node-telegram-bot-api';
8
+ import { startUpdater } from '../dist/updater.js';
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const __dirname = dirname(__filename);
12
+
13
+ // Load .env from current working directory
14
+ const envPath = join(process.cwd(), '.env');
15
+ if (existsSync(envPath)) {
16
+ config({ path: envPath });
17
+ }
18
+
19
+ const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
20
+ const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
21
+ const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
22
+ const PORT = parseInt(process.env.UPDATER_PORT || '9000', 10);
23
+
24
+ if (!BOT_TOKEN || !CHAT_ID) {
25
+ console.error('Missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID in .env');
26
+ process.exit(1);
27
+ }
28
+
29
+ if (!WEBHOOK_SECRET) {
30
+ console.error('Missing GITHUB_WEBHOOK_SECRET in .env');
31
+ process.exit(1);
32
+ }
33
+
34
+ const bot = new TelegramBot(BOT_TOKEN);
35
+
36
+ startUpdater({
37
+ port: PORT,
38
+ webhookSecret: WEBHOOK_SECRET,
39
+ telegramBot: bot,
40
+ chatId: CHAT_ID
41
+ });
@@ -0,0 +1,21 @@
1
+ import TelegramBot from 'node-telegram-bot-api';
2
+ export interface GitHubReleasePayload {
3
+ action: string;
4
+ release?: {
5
+ tag_name: string;
6
+ };
7
+ }
8
+ export declare function parseReleaseEvent(payload: GitHubReleasePayload): string | null;
9
+ export declare function verifySignature(payload: string, signature: string, secret: string): boolean;
10
+ export interface UpdateResult {
11
+ success: boolean;
12
+ error?: string;
13
+ }
14
+ export declare function executeUpdate(): Promise<UpdateResult>;
15
+ export interface UpdaterConfig {
16
+ port: number;
17
+ webhookSecret: string;
18
+ telegramBot: TelegramBot;
19
+ chatId: string;
20
+ }
21
+ export declare function startUpdater(config: UpdaterConfig): void;
@@ -0,0 +1,107 @@
1
+ import { createHmac, timingSafeEqual } from 'crypto';
2
+ import { spawn } from 'child_process';
3
+ import { createServer } from 'http';
4
+ export function parseReleaseEvent(payload) {
5
+ if (payload.action !== 'published' || !payload.release?.tag_name) {
6
+ return null;
7
+ }
8
+ const tag = payload.release.tag_name;
9
+ return tag.startsWith('v') ? tag.slice(1) : tag;
10
+ }
11
+ export function verifySignature(payload, signature, secret) {
12
+ if (!signature || !signature.startsWith('sha256=')) {
13
+ return false;
14
+ }
15
+ const expected = 'sha256=' + createHmac('sha256', secret)
16
+ .update(payload)
17
+ .digest('hex');
18
+ try {
19
+ return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ export function executeUpdate() {
26
+ return new Promise((resolve) => {
27
+ const npmProcess = spawn('npm', ['update', '-g', '@lvnt/release-radar'], {
28
+ stdio: 'inherit'
29
+ });
30
+ npmProcess.on('error', (err) => {
31
+ resolve({ success: false, error: `npm process error: ${err.message}` });
32
+ });
33
+ npmProcess.on('close', (code) => {
34
+ if (code !== 0) {
35
+ resolve({ success: false, error: `npm update failed with code ${code}` });
36
+ return;
37
+ }
38
+ const pm2Process = spawn('pm2', ['restart', 'release-radar'], {
39
+ stdio: 'inherit'
40
+ });
41
+ pm2Process.on('error', (err) => {
42
+ resolve({ success: false, error: `pm2 process error: ${err.message}` });
43
+ });
44
+ pm2Process.on('close', (pm2Code) => {
45
+ if (pm2Code !== 0) {
46
+ resolve({ success: false, error: `pm2 restart failed with code ${pm2Code}` });
47
+ }
48
+ else {
49
+ resolve({ success: true });
50
+ }
51
+ });
52
+ });
53
+ });
54
+ }
55
+ export function startUpdater(config) {
56
+ const server = createServer(async (req, res) => {
57
+ // Only accept POST /webhook
58
+ if (req.method !== 'POST' || req.url !== '/webhook') {
59
+ res.writeHead(404);
60
+ res.end('Not Found');
61
+ return;
62
+ }
63
+ // Read body
64
+ let body = '';
65
+ for await (const chunk of req) {
66
+ body += chunk;
67
+ }
68
+ // Verify signature
69
+ const signature = req.headers['x-hub-signature-256'];
70
+ if (!verifySignature(body, signature, config.webhookSecret)) {
71
+ res.writeHead(401);
72
+ res.end('Invalid signature');
73
+ return;
74
+ }
75
+ // Parse event
76
+ let payload;
77
+ try {
78
+ payload = JSON.parse(body);
79
+ }
80
+ catch {
81
+ res.writeHead(400);
82
+ res.end('Invalid JSON');
83
+ return;
84
+ }
85
+ const version = parseReleaseEvent(payload);
86
+ if (!version) {
87
+ // Not a release.published event, acknowledge but don't update
88
+ res.writeHead(200);
89
+ res.end('OK (ignored)');
90
+ return;
91
+ }
92
+ // Respond immediately, update async
93
+ res.writeHead(200);
94
+ res.end('OK');
95
+ console.log(`Received release event for v${version}, updating...`);
96
+ const result = await executeUpdate();
97
+ if (result.success) {
98
+ await config.telegramBot.sendMessage(config.chatId, `🔄 Updated release-radar to v${version}`);
99
+ }
100
+ else {
101
+ await config.telegramBot.sendMessage(config.chatId, `⚠️ Failed to update release-radar: ${result.error}`);
102
+ }
103
+ });
104
+ server.listen(config.port, () => {
105
+ console.log(`Updater webhook server listening on port ${config.port}`);
106
+ });
107
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { verifySignature, parseReleaseEvent, executeUpdate } from './updater.js';
3
+ import { spawn } from 'child_process';
4
+ vi.mock('child_process', () => ({
5
+ spawn: vi.fn()
6
+ }));
7
+ describe('verifySignature', () => {
8
+ const secret = 'test-secret';
9
+ const payload = '{"action":"published"}';
10
+ it('returns true for valid signature', () => {
11
+ // SHA256 HMAC of payload with secret
12
+ const validSig = 'sha256=0d408f71f2420c71a03fe2dd4aa32e00a84408ab43f239d7a41b4ab658e1b064';
13
+ expect(verifySignature(payload, validSig, secret)).toBe(true);
14
+ });
15
+ it('returns false for invalid signature', () => {
16
+ expect(verifySignature(payload, 'sha256=invalid', secret)).toBe(false);
17
+ });
18
+ it('returns false for missing signature', () => {
19
+ expect(verifySignature(payload, '', secret)).toBe(false);
20
+ });
21
+ });
22
+ describe('parseReleaseEvent', () => {
23
+ it('returns version for release.published event', () => {
24
+ const payload = {
25
+ action: 'published',
26
+ release: {
27
+ tag_name: 'v1.2.0'
28
+ }
29
+ };
30
+ expect(parseReleaseEvent(payload)).toBe('1.2.0');
31
+ });
32
+ it('returns null for non-published action', () => {
33
+ const payload = {
34
+ action: 'created',
35
+ release: {
36
+ tag_name: 'v1.2.0'
37
+ }
38
+ };
39
+ expect(parseReleaseEvent(payload)).toBeNull();
40
+ });
41
+ it('returns null for missing release', () => {
42
+ const payload = { action: 'published' };
43
+ expect(parseReleaseEvent(payload)).toBeNull();
44
+ });
45
+ it('strips v prefix from tag', () => {
46
+ const payload = {
47
+ action: 'published',
48
+ release: { tag_name: 'v2.0.0' }
49
+ };
50
+ expect(parseReleaseEvent(payload)).toBe('2.0.0');
51
+ });
52
+ it('handles tag without v prefix', () => {
53
+ const payload = {
54
+ action: 'published',
55
+ release: { tag_name: '2.0.0' }
56
+ };
57
+ expect(parseReleaseEvent(payload)).toBe('2.0.0');
58
+ });
59
+ });
60
+ describe('executeUpdate', () => {
61
+ beforeEach(() => {
62
+ vi.clearAllMocks();
63
+ });
64
+ it('runs npm update then pm2 restart on success', async () => {
65
+ const mockSpawn = vi.mocked(spawn);
66
+ // Mock npm update success
67
+ const npmProcess = {
68
+ on: vi.fn((event, cb) => {
69
+ if (event === 'close')
70
+ cb(0);
71
+ return npmProcess;
72
+ })
73
+ };
74
+ // Mock pm2 restart success
75
+ const pm2Process = {
76
+ on: vi.fn((event, cb) => {
77
+ if (event === 'close')
78
+ cb(0);
79
+ return pm2Process;
80
+ })
81
+ };
82
+ mockSpawn
83
+ .mockReturnValueOnce(npmProcess)
84
+ .mockReturnValueOnce(pm2Process);
85
+ const result = await executeUpdate();
86
+ expect(result.success).toBe(true);
87
+ expect(mockSpawn).toHaveBeenCalledWith('npm', ['update', '-g', '@lvnt/release-radar'], expect.any(Object));
88
+ expect(mockSpawn).toHaveBeenCalledWith('pm2', ['restart', 'release-radar'], expect.any(Object));
89
+ });
90
+ it('returns failure if npm update fails', async () => {
91
+ const mockSpawn = vi.mocked(spawn);
92
+ const npmProcess = {
93
+ on: vi.fn((event, cb) => {
94
+ if (event === 'close')
95
+ cb(1);
96
+ return npmProcess;
97
+ })
98
+ };
99
+ mockSpawn.mockReturnValueOnce(npmProcess);
100
+ const result = await executeUpdate();
101
+ expect(result.success).toBe(false);
102
+ expect(result.error).toBe('npm update failed with code 1');
103
+ });
104
+ });
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@lvnt/release-radar",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Monitor tool versions and notify via Telegram when updates are detected",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
- "release-radar": "bin/release-radar.js"
7
+ "release-radar": "bin/release-radar.js",
8
+ "release-radar-updater": "bin/release-radar-updater.js"
8
9
  },
9
10
  "type": "module",
10
11
  "scripts": {