@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 +2 -0
- package/README.md +27 -0
- package/bin/release-radar-updater.js +41 -0
- package/dist/updater.d.ts +21 -0
- package/dist/updater.js +107 -0
- package/dist/updater.test.d.ts +1 -0
- package/dist/updater.test.js +104 -0
- package/package.json +3 -2
package/.env.example
CHANGED
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;
|
package/dist/updater.js
ADDED
|
@@ -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.
|
|
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": {
|