@lvnt/release-radar 1.0.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/.env.example +2 -0
- package/README.md +188 -0
- package/bin/release-radar.js +43 -0
- package/config/tools.json +80 -0
- package/dist/checker.d.ts +10 -0
- package/dist/checker.js +35 -0
- package/dist/checker.test.d.ts +1 -0
- package/dist/checker.test.js +64 -0
- package/dist/fetchers/custom.d.ts +3 -0
- package/dist/fetchers/custom.js +62 -0
- package/dist/fetchers/custom.test.d.ts +1 -0
- package/dist/fetchers/custom.test.js +74 -0
- package/dist/fetchers/github-release.d.ts +5 -0
- package/dist/fetchers/github-release.js +21 -0
- package/dist/fetchers/github-release.test.d.ts +1 -0
- package/dist/fetchers/github-release.test.js +35 -0
- package/dist/fetchers/index.d.ts +2 -0
- package/dist/fetchers/index.js +36 -0
- package/dist/fetchers/index.test.d.ts +1 -0
- package/dist/fetchers/index.test.js +43 -0
- package/dist/fetchers/npm.d.ts +1 -0
- package/dist/fetchers/npm.js +10 -0
- package/dist/fetchers/npm.test.d.ts +1 -0
- package/dist/fetchers/npm.test.js +26 -0
- package/dist/fetchers/vscode-marketplace.d.ts +1 -0
- package/dist/fetchers/vscode-marketplace.js +25 -0
- package/dist/fetchers/vscode-marketplace.test.d.ts +1 -0
- package/dist/fetchers/vscode-marketplace.test.js +39 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +85 -0
- package/dist/notifier.d.ts +19 -0
- package/dist/notifier.js +30 -0
- package/dist/notifier.test.d.ts +1 -0
- package/dist/notifier.test.js +37 -0
- package/dist/storage.d.ts +14 -0
- package/dist/storage.js +37 -0
- package/dist/storage.test.d.ts +1 -0
- package/dist/storage.test.js +53 -0
- package/dist/types.d.ts +14 -0
- package/dist/types.js +1 -0
- package/package.json +57 -0
package/.env.example
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# ReleaseRadar
|
|
2
|
+
|
|
3
|
+
A Node.js service that monitors tool/software versions and sends Telegram notifications when updates are detected.
|
|
4
|
+
|
|
5
|
+
Built for environments with limited internet access (e.g., intranet) where manual version checking is tedious.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Monitors 15+ tools from various sources:
|
|
10
|
+
- GitHub Releases
|
|
11
|
+
- npm Registry
|
|
12
|
+
- VS Code Marketplace
|
|
13
|
+
- Custom APIs (VSCode, Claude Code CLI, CMake)
|
|
14
|
+
- Sends Telegram notifications on version changes
|
|
15
|
+
- Batched notifications (multiple updates in one message)
|
|
16
|
+
- Periodic checks via cron (configurable interval)
|
|
17
|
+
- Manual check via Telegram `/check` command
|
|
18
|
+
- Persistent version storage (survives restarts)
|
|
19
|
+
|
|
20
|
+
## Tracked Tools
|
|
21
|
+
|
|
22
|
+
| Tool | Source |
|
|
23
|
+
|------|--------|
|
|
24
|
+
| VSCode | VS Code Update API |
|
|
25
|
+
| Claude Code CLI | Google Storage / GitHub |
|
|
26
|
+
| Ninja | GitHub |
|
|
27
|
+
| CMake | cmake.org |
|
|
28
|
+
| Git | GitHub (git-for-windows) |
|
|
29
|
+
| Clangd | GitHub |
|
|
30
|
+
| Wezterm | GitHub |
|
|
31
|
+
| Ralphy | npm |
|
|
32
|
+
| vscode-cpptools | GitHub |
|
|
33
|
+
| vscode-clangd | GitHub |
|
|
34
|
+
| Claude Code VSCode | VS Code Marketplace |
|
|
35
|
+
| CMake Tools | GitHub |
|
|
36
|
+
| Roo Code | GitHub |
|
|
37
|
+
| Atlascode | GitHub |
|
|
38
|
+
| Zed | GitHub |
|
|
39
|
+
|
|
40
|
+
## Prerequisites
|
|
41
|
+
|
|
42
|
+
- Node.js 18+
|
|
43
|
+
- Telegram Bot Token (from [@BotFather](https://t.me/botfather))
|
|
44
|
+
- Telegram Chat ID (your user ID or group ID)
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# Clone the repository
|
|
50
|
+
git clone https://github.com/lvntbkdmr/release-radar.git
|
|
51
|
+
cd release-radar
|
|
52
|
+
|
|
53
|
+
# Install dependencies
|
|
54
|
+
npm install
|
|
55
|
+
|
|
56
|
+
# Configure environment
|
|
57
|
+
cp .env.example .env
|
|
58
|
+
# Edit .env with your Telegram credentials
|
|
59
|
+
|
|
60
|
+
# Build
|
|
61
|
+
npm run build
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
### Environment Variables
|
|
67
|
+
|
|
68
|
+
Create a `.env` file:
|
|
69
|
+
|
|
70
|
+
```env
|
|
71
|
+
TELEGRAM_BOT_TOKEN=your_bot_token_here
|
|
72
|
+
TELEGRAM_CHAT_ID=your_chat_id_here
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Tools Configuration
|
|
76
|
+
|
|
77
|
+
Edit `config/tools.json` to add/remove tools:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"checkIntervalHours": 6,
|
|
82
|
+
"tools": [
|
|
83
|
+
{
|
|
84
|
+
"name": "MyTool",
|
|
85
|
+
"type": "github",
|
|
86
|
+
"repo": "owner/repo"
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
#### Tool Types
|
|
93
|
+
|
|
94
|
+
| Type | Required Fields | Description |
|
|
95
|
+
|------|-----------------|-------------|
|
|
96
|
+
| `github` | `repo` | GitHub releases (e.g., `"owner/repo"`) |
|
|
97
|
+
| `npm` | `package` | npm registry package |
|
|
98
|
+
| `vscode-marketplace` | `extensionId` | VS Code extension (e.g., `"publisher.extension"`) |
|
|
99
|
+
| `custom` | `customFetcher` | Built-in fetchers: `vscode`, `claude-cli`, `cmake` |
|
|
100
|
+
|
|
101
|
+
## Usage
|
|
102
|
+
|
|
103
|
+
### Development
|
|
104
|
+
```bash
|
|
105
|
+
npm run dev
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Production
|
|
109
|
+
```bash
|
|
110
|
+
npm run build
|
|
111
|
+
npm start
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### With pm2 (recommended)
|
|
115
|
+
```bash
|
|
116
|
+
npm install -g pm2
|
|
117
|
+
pm2 start npm --name "release-radar" -- start
|
|
118
|
+
pm2 save
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Telegram Commands
|
|
122
|
+
|
|
123
|
+
| Command | Description |
|
|
124
|
+
|---------|-------------|
|
|
125
|
+
| `/check` | Manually trigger version check |
|
|
126
|
+
| `/status` | Show all tracked versions |
|
|
127
|
+
| `/interval` | Show current check interval |
|
|
128
|
+
| `/setinterval <hours>` | Set check interval (1-24 hours) |
|
|
129
|
+
|
|
130
|
+
## Project Structure
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
release-radar/
|
|
134
|
+
├── src/
|
|
135
|
+
│ ├── index.ts # Main entry point
|
|
136
|
+
│ ├── checker.ts # Version check orchestration
|
|
137
|
+
│ ├── storage.ts # JSON persistence
|
|
138
|
+
│ ├── notifier.ts # Telegram notifications
|
|
139
|
+
│ ├── types.ts # TypeScript interfaces
|
|
140
|
+
│ └── fetchers/
|
|
141
|
+
│ ├── index.ts # Fetcher registry
|
|
142
|
+
│ ├── github-release.ts
|
|
143
|
+
│ ├── npm.ts
|
|
144
|
+
│ ├── vscode-marketplace.ts
|
|
145
|
+
│ └── custom.ts
|
|
146
|
+
├── config/
|
|
147
|
+
│ └── tools.json # Tool configuration
|
|
148
|
+
├── data/
|
|
149
|
+
│ └── versions.json # Persisted version state
|
|
150
|
+
├── docs/
|
|
151
|
+
│ └── OPERATIONS.md # Operations guide
|
|
152
|
+
└── dist/ # Compiled JavaScript
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Testing
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
# Run all tests
|
|
159
|
+
npm test
|
|
160
|
+
|
|
161
|
+
# Watch mode
|
|
162
|
+
npm run test:watch
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Notifications
|
|
166
|
+
|
|
167
|
+
### Version Update
|
|
168
|
+
```
|
|
169
|
+
🔄 Ninja: 1.11.1 → 1.12.0
|
|
170
|
+
🔄 Git: 2.43.0 → 2.44.0
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Fetch Failure
|
|
174
|
+
```
|
|
175
|
+
⚠️ Failed to check CMake: Request timeout
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
## Operations
|
|
179
|
+
|
|
180
|
+
See [docs/OPERATIONS.md](docs/OPERATIONS.md) for detailed instructions on:
|
|
181
|
+
- Starting/stopping the service
|
|
182
|
+
- Viewing logs
|
|
183
|
+
- Auto-start configuration
|
|
184
|
+
- Troubleshooting
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
ISC
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
import { existsSync, mkdirSync, copyFileSync } from 'fs';
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
const packageRoot = join(__dirname, '..');
|
|
10
|
+
|
|
11
|
+
// Ensure config directory exists in current working directory
|
|
12
|
+
const configDir = join(process.cwd(), 'config');
|
|
13
|
+
const dataDir = join(process.cwd(), 'data');
|
|
14
|
+
|
|
15
|
+
if (!existsSync(configDir)) {
|
|
16
|
+
mkdirSync(configDir, { recursive: true });
|
|
17
|
+
// Copy default config
|
|
18
|
+
const defaultConfig = join(packageRoot, 'config', 'tools.json');
|
|
19
|
+
if (existsSync(defaultConfig)) {
|
|
20
|
+
copyFileSync(defaultConfig, join(configDir, 'tools.json'));
|
|
21
|
+
console.log('Created config/tools.json with default configuration');
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (!existsSync(dataDir)) {
|
|
26
|
+
mkdirSync(dataDir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Check for .env file
|
|
30
|
+
const envFile = join(process.cwd(), '.env');
|
|
31
|
+
if (!existsSync(envFile)) {
|
|
32
|
+
const envExample = join(packageRoot, '.env.example');
|
|
33
|
+
if (existsSync(envExample)) {
|
|
34
|
+
copyFileSync(envExample, envFile);
|
|
35
|
+
console.log('Created .env file - please edit with your Telegram credentials');
|
|
36
|
+
console.log(' TELEGRAM_BOT_TOKEN=your_bot_token_here');
|
|
37
|
+
console.log(' TELEGRAM_CHAT_ID=your_chat_id_here');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Run the main application
|
|
43
|
+
import('../dist/index.js');
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"checkIntervalHours": 1,
|
|
3
|
+
"tools": [
|
|
4
|
+
{
|
|
5
|
+
"name": "VSCode",
|
|
6
|
+
"type": "custom",
|
|
7
|
+
"customFetcher": "vscode"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"name": "Claude Code CLI",
|
|
11
|
+
"type": "custom",
|
|
12
|
+
"customFetcher": "claude-cli"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"name": "Ninja",
|
|
16
|
+
"type": "github",
|
|
17
|
+
"repo": "ninja-build/ninja"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "CMake",
|
|
21
|
+
"type": "custom",
|
|
22
|
+
"customFetcher": "cmake"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "Git",
|
|
26
|
+
"type": "github",
|
|
27
|
+
"repo": "git-for-windows/git"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "Clangd",
|
|
31
|
+
"type": "github",
|
|
32
|
+
"repo": "clangd/clangd"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "Wezterm",
|
|
36
|
+
"type": "github",
|
|
37
|
+
"repo": "wezterm/wezterm"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "Ralphy",
|
|
41
|
+
"type": "npm",
|
|
42
|
+
"package": "ralphy-cli"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"name": "vscode-cpptools",
|
|
46
|
+
"type": "github",
|
|
47
|
+
"repo": "microsoft/vscode-cpptools"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"name": "vscode-clangd",
|
|
51
|
+
"type": "github",
|
|
52
|
+
"repo": "clangd/vscode-clangd"
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"name": "Claude Code VSCode",
|
|
56
|
+
"type": "vscode-marketplace",
|
|
57
|
+
"extensionId": "anthropic.claude-code"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "CMake Tools",
|
|
61
|
+
"type": "github",
|
|
62
|
+
"repo": "microsoft/vscode-cmake-tools"
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"name": "Roo Code",
|
|
66
|
+
"type": "github",
|
|
67
|
+
"repo": "RooCodeInc/Roo-Code"
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
"name": "Atlascode",
|
|
71
|
+
"type": "github",
|
|
72
|
+
"repo": "atlassian/atlascode"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "Zed",
|
|
76
|
+
"type": "github",
|
|
77
|
+
"repo": "zed-industries/zed"
|
|
78
|
+
}
|
|
79
|
+
]
|
|
80
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ToolConfig } from './types.js';
|
|
2
|
+
import type { Storage } from './storage.js';
|
|
3
|
+
import type { Notifier } from './notifier.js';
|
|
4
|
+
export declare class Checker {
|
|
5
|
+
private tools;
|
|
6
|
+
private storage;
|
|
7
|
+
private notifier;
|
|
8
|
+
constructor(tools: ToolConfig[], storage: Storage, notifier: Notifier);
|
|
9
|
+
checkAll(): Promise<void>;
|
|
10
|
+
}
|
package/dist/checker.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { fetchVersion } from './fetchers/index.js';
|
|
2
|
+
export class Checker {
|
|
3
|
+
tools;
|
|
4
|
+
storage;
|
|
5
|
+
notifier;
|
|
6
|
+
constructor(tools, storage, notifier) {
|
|
7
|
+
this.tools = tools;
|
|
8
|
+
this.storage = storage;
|
|
9
|
+
this.notifier = notifier;
|
|
10
|
+
}
|
|
11
|
+
async checkAll() {
|
|
12
|
+
const updates = [];
|
|
13
|
+
const failures = [];
|
|
14
|
+
for (const tool of this.tools) {
|
|
15
|
+
try {
|
|
16
|
+
const newVersion = await fetchVersion(tool);
|
|
17
|
+
const oldVersion = this.storage.getVersion(tool.name);
|
|
18
|
+
if (oldVersion === null) {
|
|
19
|
+
// First run - store without notifying
|
|
20
|
+
this.storage.setVersion(tool.name, newVersion);
|
|
21
|
+
}
|
|
22
|
+
else if (oldVersion !== newVersion) {
|
|
23
|
+
updates.push({ name: tool.name, oldVersion, newVersion });
|
|
24
|
+
this.storage.setVersion(tool.name, newVersion);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
29
|
+
failures.push({ name: tool.name, error: message });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
await this.notifier.sendBatchedUpdates(updates);
|
|
33
|
+
await this.notifier.sendBatchedFailures(failures);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/checker.test.ts
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { Checker } from './checker.js';
|
|
4
|
+
vi.mock('./fetchers/index.js', () => ({
|
|
5
|
+
fetchVersion: vi.fn()
|
|
6
|
+
}));
|
|
7
|
+
import { fetchVersion } from './fetchers/index.js';
|
|
8
|
+
describe('Checker', () => {
|
|
9
|
+
let mockStorage;
|
|
10
|
+
let mockNotifier;
|
|
11
|
+
let checker;
|
|
12
|
+
let tools;
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
vi.clearAllMocks();
|
|
15
|
+
mockStorage = {
|
|
16
|
+
getVersion: vi.fn(),
|
|
17
|
+
setVersion: vi.fn()
|
|
18
|
+
};
|
|
19
|
+
mockNotifier = {
|
|
20
|
+
sendBatchedUpdates: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
sendBatchedFailures: vi.fn().mockResolvedValue(undefined)
|
|
22
|
+
};
|
|
23
|
+
tools = [
|
|
24
|
+
{ name: 'Ninja', type: 'github', repo: 'ninja-build/ninja' },
|
|
25
|
+
{ name: 'Git', type: 'github', repo: 'git-for-windows/git' }
|
|
26
|
+
];
|
|
27
|
+
checker = new Checker(tools, mockStorage, mockNotifier);
|
|
28
|
+
});
|
|
29
|
+
it('notifies on version change', async () => {
|
|
30
|
+
mockStorage.getVersion.mockReturnValueOnce('1.11.1').mockReturnValueOnce('2.43.0');
|
|
31
|
+
vi.mocked(fetchVersion)
|
|
32
|
+
.mockResolvedValueOnce('1.12.0')
|
|
33
|
+
.mockResolvedValueOnce('2.43.0');
|
|
34
|
+
await checker.checkAll();
|
|
35
|
+
expect(mockNotifier.sendBatchedUpdates).toHaveBeenCalledWith([
|
|
36
|
+
{ name: 'Ninja', oldVersion: '1.11.1', newVersion: '1.12.0' }
|
|
37
|
+
]);
|
|
38
|
+
expect(mockStorage.setVersion).toHaveBeenCalledWith('Ninja', '1.12.0');
|
|
39
|
+
});
|
|
40
|
+
it('skips notification on first run (no stored version)', async () => {
|
|
41
|
+
mockStorage.getVersion.mockReturnValue(null);
|
|
42
|
+
vi.mocked(fetchVersion).mockResolvedValue('1.12.0');
|
|
43
|
+
await checker.checkAll();
|
|
44
|
+
expect(mockNotifier.sendBatchedUpdates).toHaveBeenCalledWith([]);
|
|
45
|
+
expect(mockStorage.setVersion).toHaveBeenCalledTimes(2);
|
|
46
|
+
});
|
|
47
|
+
it('notifies on fetch failure', async () => {
|
|
48
|
+
mockStorage.getVersion.mockReturnValue('1.11.1');
|
|
49
|
+
vi.mocked(fetchVersion)
|
|
50
|
+
.mockRejectedValueOnce(new Error('Timeout'))
|
|
51
|
+
.mockResolvedValueOnce('2.43.0');
|
|
52
|
+
await checker.checkAll();
|
|
53
|
+
expect(mockNotifier.sendBatchedFailures).toHaveBeenCalledWith([
|
|
54
|
+
{ name: 'Ninja', error: 'Timeout' }
|
|
55
|
+
]);
|
|
56
|
+
});
|
|
57
|
+
it('does not notify when version unchanged', async () => {
|
|
58
|
+
mockStorage.getVersion.mockReturnValue('1.12.0');
|
|
59
|
+
vi.mocked(fetchVersion).mockResolvedValue('1.12.0');
|
|
60
|
+
await checker.checkAll();
|
|
61
|
+
expect(mockNotifier.sendBatchedUpdates).toHaveBeenCalledWith([]);
|
|
62
|
+
expect(mockStorage.setVersion).not.toHaveBeenCalled();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// src/fetchers/custom.ts
|
|
2
|
+
export async function fetchVSCodeVersion() {
|
|
3
|
+
const response = await fetch('https://update.code.visualstudio.com/api/releases/stable');
|
|
4
|
+
if (!response.ok) {
|
|
5
|
+
throw new Error(`VSCode API error: ${response.status} ${response.statusText}`);
|
|
6
|
+
}
|
|
7
|
+
const releases = await response.json();
|
|
8
|
+
return releases[0];
|
|
9
|
+
}
|
|
10
|
+
export async function fetchClaudeCodeCLI() {
|
|
11
|
+
const primaryUrl = 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/latest';
|
|
12
|
+
const fallbackUrl = 'https://api.github.com/repos/anthropics/claude-code/releases/latest';
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetch(primaryUrl);
|
|
15
|
+
if (response.ok) {
|
|
16
|
+
const version = await response.text();
|
|
17
|
+
return version.trim();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// Fall through to fallback
|
|
22
|
+
}
|
|
23
|
+
const response = await fetch(fallbackUrl, {
|
|
24
|
+
headers: {
|
|
25
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
26
|
+
'User-Agent': 'ReleaseRadar/1.0'
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(`Claude Code CLI fetch failed: ${response.status}`);
|
|
31
|
+
}
|
|
32
|
+
const data = await response.json();
|
|
33
|
+
return data.tag_name.replace(/^v/, '');
|
|
34
|
+
}
|
|
35
|
+
export async function fetchCMakeVersion() {
|
|
36
|
+
const jsonUrl = 'https://cmake.org/files/LatestRelease/cmake-latest-files-v1.json';
|
|
37
|
+
const fallbackUrl = 'https://cmake.org/files/LatestRelease/';
|
|
38
|
+
// Try JSON endpoint first
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(jsonUrl);
|
|
41
|
+
if (response.ok) {
|
|
42
|
+
const data = await response.json();
|
|
43
|
+
return data.version.string;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Fall through to HTML parsing
|
|
48
|
+
}
|
|
49
|
+
// Fallback: parse HTML directory listing (items are oldest to newest)
|
|
50
|
+
const response = await fetch(fallbackUrl);
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(`CMake fetch error: ${response.status} ${response.statusText}`);
|
|
53
|
+
}
|
|
54
|
+
const html = await response.text();
|
|
55
|
+
const matches = html.matchAll(/cmake-(\d+\.\d+\.\d+)/g);
|
|
56
|
+
const versions = [...matches].map(m => m[1]);
|
|
57
|
+
if (versions.length === 0) {
|
|
58
|
+
throw new Error('Could not parse CMake version from directory listing');
|
|
59
|
+
}
|
|
60
|
+
// Return last match (newest version)
|
|
61
|
+
return versions[versions.length - 1];
|
|
62
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// src/fetchers/custom.test.ts
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { fetchVSCodeVersion, fetchClaudeCodeCLI, fetchCMakeVersion } from './custom.js';
|
|
4
|
+
describe('custom fetchers', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
describe('fetchVSCodeVersion', () => {
|
|
9
|
+
it('extracts first version from releases array', async () => {
|
|
10
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
11
|
+
ok: true,
|
|
12
|
+
json: () => Promise.resolve(['1.96.0', '1.95.3', '1.95.2'])
|
|
13
|
+
});
|
|
14
|
+
const version = await fetchVSCodeVersion();
|
|
15
|
+
expect(version).toBe('1.96.0');
|
|
16
|
+
expect(fetch).toHaveBeenCalledWith('https://update.code.visualstudio.com/api/releases/stable');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe('fetchClaudeCodeCLI', () => {
|
|
20
|
+
it('extracts version from text response', async () => {
|
|
21
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
22
|
+
ok: true,
|
|
23
|
+
text: () => Promise.resolve('1.0.5')
|
|
24
|
+
});
|
|
25
|
+
const version = await fetchClaudeCodeCLI();
|
|
26
|
+
expect(version).toBe('1.0.5');
|
|
27
|
+
});
|
|
28
|
+
it('falls back to GitHub on primary failure', async () => {
|
|
29
|
+
global.fetch = vi.fn()
|
|
30
|
+
.mockResolvedValueOnce({ ok: false, status: 503 })
|
|
31
|
+
.mockResolvedValueOnce({
|
|
32
|
+
ok: true,
|
|
33
|
+
json: () => Promise.resolve({ tag_name: 'v1.0.6' })
|
|
34
|
+
});
|
|
35
|
+
const version = await fetchClaudeCodeCLI();
|
|
36
|
+
expect(version).toBe('1.0.6');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
describe('fetchCMakeVersion', () => {
|
|
40
|
+
it('extracts version from JSON endpoint', async () => {
|
|
41
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
42
|
+
ok: true,
|
|
43
|
+
json: () => Promise.resolve({ version: { string: '3.31.0' } })
|
|
44
|
+
});
|
|
45
|
+
const version = await fetchCMakeVersion();
|
|
46
|
+
expect(version).toBe('3.31.0');
|
|
47
|
+
expect(fetch).toHaveBeenCalledWith('https://cmake.org/files/LatestRelease/cmake-latest-files-v1.json');
|
|
48
|
+
});
|
|
49
|
+
it('falls back to HTML and returns newest (last) version', async () => {
|
|
50
|
+
const html = `
|
|
51
|
+
<a href="cmake-3.28.0-linux-x86_64.tar.gz">cmake-3.28.0-linux-x86_64.tar.gz</a>
|
|
52
|
+
<a href="cmake-3.31.0-linux-x86_64.tar.gz">cmake-3.31.0-linux-x86_64.tar.gz</a>
|
|
53
|
+
`;
|
|
54
|
+
global.fetch = vi.fn()
|
|
55
|
+
.mockResolvedValueOnce({ ok: false, status: 404 })
|
|
56
|
+
.mockResolvedValueOnce({
|
|
57
|
+
ok: true,
|
|
58
|
+
text: () => Promise.resolve(html)
|
|
59
|
+
});
|
|
60
|
+
const version = await fetchCMakeVersion();
|
|
61
|
+
expect(version).toBe('3.31.0');
|
|
62
|
+
});
|
|
63
|
+
it('throws when no version found in listing', async () => {
|
|
64
|
+
global.fetch = vi.fn()
|
|
65
|
+
.mockResolvedValueOnce({ ok: false, status: 404 })
|
|
66
|
+
.mockResolvedValueOnce({
|
|
67
|
+
ok: true,
|
|
68
|
+
text: () => Promise.resolve('<html>empty</html>')
|
|
69
|
+
});
|
|
70
|
+
await expect(fetchCMakeVersion())
|
|
71
|
+
.rejects.toThrow('Could not parse CMake version from directory listing');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/fetchers/github-release.ts
|
|
2
|
+
/**
|
|
3
|
+
* Fetches the latest stable release version from a GitHub repository.
|
|
4
|
+
* Uses /releases/latest endpoint which excludes pre-releases and drafts by design.
|
|
5
|
+
*/
|
|
6
|
+
export async function fetchGitHubRelease(repo) {
|
|
7
|
+
// Note: /releases/latest only returns stable releases (no pre-releases or drafts)
|
|
8
|
+
const url = `https://api.github.com/repos/${repo}/releases/latest`;
|
|
9
|
+
const response = await fetch(url, {
|
|
10
|
+
headers: {
|
|
11
|
+
'Accept': 'application/vnd.github.v3+json',
|
|
12
|
+
'User-Agent': 'ReleaseRadar/1.0'
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
if (!response.ok) {
|
|
16
|
+
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
17
|
+
}
|
|
18
|
+
const data = await response.json();
|
|
19
|
+
const version = data.tag_name.replace(/^v/, '');
|
|
20
|
+
return version;
|
|
21
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// src/fetchers/github-release.test.ts
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { fetchGitHubRelease } from './github-release.js';
|
|
4
|
+
// Note: Uses /releases/latest which only returns stable releases (excludes pre-releases and drafts)
|
|
5
|
+
describe('fetchGitHubRelease', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.restoreAllMocks();
|
|
8
|
+
});
|
|
9
|
+
it('fetches latest stable release (not pre-release)', async () => {
|
|
10
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
11
|
+
ok: true,
|
|
12
|
+
json: () => Promise.resolve({ tag_name: 'v1.12.0' })
|
|
13
|
+
});
|
|
14
|
+
const version = await fetchGitHubRelease('ninja-build/ninja');
|
|
15
|
+
expect(version).toBe('1.12.0');
|
|
16
|
+
expect(fetch).toHaveBeenCalledWith('https://api.github.com/repos/ninja-build/ninja/releases/latest', expect.any(Object));
|
|
17
|
+
});
|
|
18
|
+
it('handles tag without v prefix', async () => {
|
|
19
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
20
|
+
ok: true,
|
|
21
|
+
json: () => Promise.resolve({ tag_name: '2.44.0' })
|
|
22
|
+
});
|
|
23
|
+
const version = await fetchGitHubRelease('git-for-windows/git');
|
|
24
|
+
expect(version).toBe('2.44.0');
|
|
25
|
+
});
|
|
26
|
+
it('throws on API error', async () => {
|
|
27
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
28
|
+
ok: false,
|
|
29
|
+
status: 404,
|
|
30
|
+
statusText: 'Not Found'
|
|
31
|
+
});
|
|
32
|
+
await expect(fetchGitHubRelease('invalid/repo'))
|
|
33
|
+
.rejects.toThrow('GitHub API error: 404 Not Found');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { fetchGitHubRelease } from './github-release.js';
|
|
2
|
+
import { fetchNpmVersion } from './npm.js';
|
|
3
|
+
import { fetchVSCodeMarketplace } from './vscode-marketplace.js';
|
|
4
|
+
import { fetchVSCodeVersion, fetchClaudeCodeCLI, fetchCMakeVersion } from './custom.js';
|
|
5
|
+
export async function fetchVersion(tool) {
|
|
6
|
+
switch (tool.type) {
|
|
7
|
+
case 'github':
|
|
8
|
+
if (!tool.repo)
|
|
9
|
+
throw new Error(`Missing repo for ${tool.name}`);
|
|
10
|
+
return fetchGitHubRelease(tool.repo);
|
|
11
|
+
case 'npm':
|
|
12
|
+
if (!tool.package)
|
|
13
|
+
throw new Error(`Missing package for ${tool.name}`);
|
|
14
|
+
return fetchNpmVersion(tool.package);
|
|
15
|
+
case 'vscode-marketplace':
|
|
16
|
+
if (!tool.extensionId)
|
|
17
|
+
throw new Error(`Missing extensionId for ${tool.name}`);
|
|
18
|
+
return fetchVSCodeMarketplace(tool.extensionId);
|
|
19
|
+
case 'custom':
|
|
20
|
+
return fetchCustom(tool);
|
|
21
|
+
default:
|
|
22
|
+
throw new Error(`Unknown tool type: ${tool.type}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function fetchCustom(tool) {
|
|
26
|
+
switch (tool.customFetcher) {
|
|
27
|
+
case 'vscode':
|
|
28
|
+
return fetchVSCodeVersion();
|
|
29
|
+
case 'claude-cli':
|
|
30
|
+
return fetchClaudeCodeCLI();
|
|
31
|
+
case 'cmake':
|
|
32
|
+
return fetchCMakeVersion();
|
|
33
|
+
default:
|
|
34
|
+
throw new Error(`Unknown custom fetcher: ${tool.customFetcher}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/fetchers/index.test.ts
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
3
|
+
import { fetchVersion } from './index.js';
|
|
4
|
+
vi.mock('./github-release.js', () => ({
|
|
5
|
+
fetchGitHubRelease: vi.fn().mockResolvedValue('1.12.0')
|
|
6
|
+
}));
|
|
7
|
+
vi.mock('./npm.js', () => ({
|
|
8
|
+
fetchNpmVersion: vi.fn().mockResolvedValue('2.1.0')
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('./vscode-marketplace.js', () => ({
|
|
11
|
+
fetchVSCodeMarketplace: vi.fn().mockResolvedValue('1.2.3')
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('./custom.js', () => ({
|
|
14
|
+
fetchVSCodeVersion: vi.fn().mockResolvedValue('1.96.0'),
|
|
15
|
+
fetchClaudeCodeCLI: vi.fn().mockResolvedValue('1.0.5'),
|
|
16
|
+
fetchCMakeVersion: vi.fn().mockResolvedValue('3.28.0')
|
|
17
|
+
}));
|
|
18
|
+
describe('fetchVersion', () => {
|
|
19
|
+
it('routes github type to GitHub fetcher', async () => {
|
|
20
|
+
const tool = { name: 'Ninja', type: 'github', repo: 'ninja-build/ninja' };
|
|
21
|
+
const version = await fetchVersion(tool);
|
|
22
|
+
expect(version).toBe('1.12.0');
|
|
23
|
+
});
|
|
24
|
+
it('routes npm type to npm fetcher', async () => {
|
|
25
|
+
const tool = { name: 'Ralphy', type: 'npm', package: 'ralphy-cli' };
|
|
26
|
+
const version = await fetchVersion(tool);
|
|
27
|
+
expect(version).toBe('2.1.0');
|
|
28
|
+
});
|
|
29
|
+
it('routes vscode-marketplace type', async () => {
|
|
30
|
+
const tool = { name: 'Claude Code', type: 'vscode-marketplace', extensionId: 'anthropic.claude-code' };
|
|
31
|
+
const version = await fetchVersion(tool);
|
|
32
|
+
expect(version).toBe('1.2.3');
|
|
33
|
+
});
|
|
34
|
+
it('routes custom type with customFetcher', async () => {
|
|
35
|
+
const tool = { name: 'VSCode', type: 'custom', customFetcher: 'vscode' };
|
|
36
|
+
const version = await fetchVersion(tool);
|
|
37
|
+
expect(version).toBe('1.96.0');
|
|
38
|
+
});
|
|
39
|
+
it('throws for unknown type', async () => {
|
|
40
|
+
const tool = { name: 'Unknown', type: 'invalid' };
|
|
41
|
+
await expect(fetchVersion(tool)).rejects.toThrow('Unknown tool type: invalid');
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fetchNpmVersion(packageName: string): Promise<string>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/fetchers/npm.ts
|
|
2
|
+
export async function fetchNpmVersion(packageName) {
|
|
3
|
+
const url = `https://registry.npmjs.org/${packageName}/latest`;
|
|
4
|
+
const response = await fetch(url);
|
|
5
|
+
if (!response.ok) {
|
|
6
|
+
throw new Error(`npm registry error: ${response.status} ${response.statusText}`);
|
|
7
|
+
}
|
|
8
|
+
const data = await response.json();
|
|
9
|
+
return data.version;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/fetchers/npm.test.ts
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { fetchNpmVersion } from './npm.js';
|
|
4
|
+
describe('fetchNpmVersion', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
it('extracts version from npm registry', async () => {
|
|
9
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
10
|
+
ok: true,
|
|
11
|
+
json: () => Promise.resolve({ version: '2.1.0' })
|
|
12
|
+
});
|
|
13
|
+
const version = await fetchNpmVersion('ralphy-cli');
|
|
14
|
+
expect(version).toBe('2.1.0');
|
|
15
|
+
expect(fetch).toHaveBeenCalledWith('https://registry.npmjs.org/ralphy-cli/latest');
|
|
16
|
+
});
|
|
17
|
+
it('throws on registry error', async () => {
|
|
18
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
19
|
+
ok: false,
|
|
20
|
+
status: 404,
|
|
21
|
+
statusText: 'Not Found'
|
|
22
|
+
});
|
|
23
|
+
await expect(fetchNpmVersion('nonexistent-package'))
|
|
24
|
+
.rejects.toThrow('npm registry error: 404 Not Found');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function fetchVSCodeMarketplace(extensionId: string): Promise<string>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export async function fetchVSCodeMarketplace(extensionId) {
|
|
2
|
+
const url = 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery';
|
|
3
|
+
const response = await fetch(url, {
|
|
4
|
+
method: 'POST',
|
|
5
|
+
headers: {
|
|
6
|
+
'Content-Type': 'application/json',
|
|
7
|
+
'Accept': 'application/json;api-version=7.1-preview.1'
|
|
8
|
+
},
|
|
9
|
+
body: JSON.stringify({
|
|
10
|
+
filters: [{
|
|
11
|
+
criteria: [{ filterType: 7, value: extensionId }]
|
|
12
|
+
}],
|
|
13
|
+
flags: 0x200 // Include versions
|
|
14
|
+
})
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
throw new Error(`VS Code Marketplace error: ${response.status} ${response.statusText}`);
|
|
18
|
+
}
|
|
19
|
+
const data = await response.json();
|
|
20
|
+
const extension = data.results[0]?.extensions[0];
|
|
21
|
+
if (!extension) {
|
|
22
|
+
throw new Error(`Extension not found: ${extensionId}`);
|
|
23
|
+
}
|
|
24
|
+
return extension.versions[0].version;
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// src/fetchers/vscode-marketplace.test.ts
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { fetchVSCodeMarketplace } from './vscode-marketplace.js';
|
|
4
|
+
describe('fetchVSCodeMarketplace', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
it('extracts version from marketplace API', async () => {
|
|
9
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
10
|
+
ok: true,
|
|
11
|
+
json: () => Promise.resolve({
|
|
12
|
+
results: [{
|
|
13
|
+
extensions: [{
|
|
14
|
+
versions: [{ version: '1.2.3' }]
|
|
15
|
+
}]
|
|
16
|
+
}]
|
|
17
|
+
})
|
|
18
|
+
});
|
|
19
|
+
const version = await fetchVSCodeMarketplace('anthropic.claude-code');
|
|
20
|
+
expect(version).toBe('1.2.3');
|
|
21
|
+
});
|
|
22
|
+
it('throws on API error', async () => {
|
|
23
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
24
|
+
ok: false,
|
|
25
|
+
status: 500,
|
|
26
|
+
statusText: 'Internal Server Error'
|
|
27
|
+
});
|
|
28
|
+
await expect(fetchVSCodeMarketplace('some.extension'))
|
|
29
|
+
.rejects.toThrow('VS Code Marketplace error: 500 Internal Server Error');
|
|
30
|
+
});
|
|
31
|
+
it('throws when extension not found', async () => {
|
|
32
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
33
|
+
ok: true,
|
|
34
|
+
json: () => Promise.resolve({ results: [{ extensions: [] }] })
|
|
35
|
+
});
|
|
36
|
+
await expect(fetchVSCodeMarketplace('nonexistent.extension'))
|
|
37
|
+
.rejects.toThrow('Extension not found: nonexistent.extension');
|
|
38
|
+
});
|
|
39
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { config } from 'dotenv';
|
|
3
|
+
config();
|
|
4
|
+
import TelegramBot from 'node-telegram-bot-api';
|
|
5
|
+
import cron from 'node-cron';
|
|
6
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
7
|
+
import { Storage } from './storage.js';
|
|
8
|
+
import { Notifier } from './notifier.js';
|
|
9
|
+
import { Checker } from './checker.js';
|
|
10
|
+
const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
|
|
11
|
+
const CHAT_ID = process.env.TELEGRAM_CHAT_ID;
|
|
12
|
+
const CONFIG_PATH = './config/tools.json';
|
|
13
|
+
if (!BOT_TOKEN || !CHAT_ID) {
|
|
14
|
+
console.error('Missing TELEGRAM_BOT_TOKEN or TELEGRAM_CHAT_ID environment variables');
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
// Load config
|
|
18
|
+
let configData = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
|
19
|
+
// Initialize components
|
|
20
|
+
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
|
21
|
+
const storage = new Storage('./data/versions.json');
|
|
22
|
+
const notifier = new Notifier(bot, CHAT_ID);
|
|
23
|
+
const checker = new Checker(configData.tools, storage, notifier);
|
|
24
|
+
// Track scheduled task for rescheduling
|
|
25
|
+
let scheduledTask = null;
|
|
26
|
+
function scheduleChecks(intervalHours) {
|
|
27
|
+
if (scheduledTask) {
|
|
28
|
+
scheduledTask.stop();
|
|
29
|
+
}
|
|
30
|
+
const cronExpression = `0 */${intervalHours} * * *`;
|
|
31
|
+
scheduledTask = cron.schedule(cronExpression, async () => {
|
|
32
|
+
console.log(`[${new Date().toISOString()}] Running scheduled check`);
|
|
33
|
+
await checker.checkAll();
|
|
34
|
+
});
|
|
35
|
+
console.log(`Scheduled checks every ${intervalHours} hours`);
|
|
36
|
+
}
|
|
37
|
+
// Bot commands
|
|
38
|
+
bot.onText(/\/check/, async (msg) => {
|
|
39
|
+
if (msg.chat.id.toString() !== CHAT_ID)
|
|
40
|
+
return;
|
|
41
|
+
await bot.sendMessage(CHAT_ID, 'Checking for updates...');
|
|
42
|
+
await checker.checkAll();
|
|
43
|
+
await bot.sendMessage(CHAT_ID, 'Check complete.');
|
|
44
|
+
});
|
|
45
|
+
bot.onText(/\/status/, async (msg) => {
|
|
46
|
+
if (msg.chat.id.toString() !== CHAT_ID)
|
|
47
|
+
return;
|
|
48
|
+
const state = storage.load();
|
|
49
|
+
const lines = Object.entries(state.versions)
|
|
50
|
+
.map(([name, version]) => `${name}: ${version}`)
|
|
51
|
+
.sort();
|
|
52
|
+
const message = lines.length > 0
|
|
53
|
+
? lines.join('\n')
|
|
54
|
+
: 'No versions tracked yet. Run /check first.';
|
|
55
|
+
await bot.sendMessage(CHAT_ID, message);
|
|
56
|
+
});
|
|
57
|
+
bot.onText(/\/interval$/, async (msg) => {
|
|
58
|
+
if (msg.chat.id.toString() !== CHAT_ID)
|
|
59
|
+
return;
|
|
60
|
+
await bot.sendMessage(CHAT_ID, `Check interval: every ${configData.checkIntervalHours} hours`);
|
|
61
|
+
});
|
|
62
|
+
bot.onText(/\/setinterval(?:\s+(\d+))?/, async (msg, match) => {
|
|
63
|
+
if (msg.chat.id.toString() !== CHAT_ID)
|
|
64
|
+
return;
|
|
65
|
+
const hoursStr = match?.[1];
|
|
66
|
+
if (!hoursStr) {
|
|
67
|
+
await bot.sendMessage(CHAT_ID, 'Usage: /setinterval <hours>\nExample: /setinterval 12');
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const hours = parseInt(hoursStr, 10);
|
|
71
|
+
if (hours < 1 || hours > 24) {
|
|
72
|
+
await bot.sendMessage(CHAT_ID, 'Interval must be between 1 and 24 hours');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Update config
|
|
76
|
+
configData.checkIntervalHours = hours;
|
|
77
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(configData, null, 2));
|
|
78
|
+
// Reschedule
|
|
79
|
+
scheduleChecks(hours);
|
|
80
|
+
await bot.sendMessage(CHAT_ID, `Check interval updated to every ${hours} hours`);
|
|
81
|
+
});
|
|
82
|
+
// Start scheduled checks
|
|
83
|
+
scheduleChecks(configData.checkIntervalHours);
|
|
84
|
+
console.log(`ReleaseRadar started. Checking every ${configData.checkIntervalHours} hours.`);
|
|
85
|
+
console.log(`Tracking ${configData.tools.length} tools.`);
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import TelegramBot from 'node-telegram-bot-api';
|
|
2
|
+
export interface UpdateInfo {
|
|
3
|
+
name: string;
|
|
4
|
+
oldVersion: string;
|
|
5
|
+
newVersion: string;
|
|
6
|
+
}
|
|
7
|
+
export interface FailureInfo {
|
|
8
|
+
name: string;
|
|
9
|
+
error: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class Notifier {
|
|
12
|
+
private bot;
|
|
13
|
+
private chatId;
|
|
14
|
+
constructor(bot: TelegramBot, chatId: string);
|
|
15
|
+
sendUpdate(name: string, oldVersion: string, newVersion: string): Promise<void>;
|
|
16
|
+
sendBatchedUpdates(updates: UpdateInfo[]): Promise<void>;
|
|
17
|
+
sendFailure(name: string, error: string): Promise<void>;
|
|
18
|
+
sendBatchedFailures(failures: FailureInfo[]): Promise<void>;
|
|
19
|
+
}
|
package/dist/notifier.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export class Notifier {
|
|
2
|
+
bot;
|
|
3
|
+
chatId;
|
|
4
|
+
constructor(bot, chatId) {
|
|
5
|
+
this.bot = bot;
|
|
6
|
+
this.chatId = chatId;
|
|
7
|
+
}
|
|
8
|
+
async sendUpdate(name, oldVersion, newVersion) {
|
|
9
|
+
await this.bot.sendMessage(this.chatId, `🔄 ${name}: ${oldVersion} → ${newVersion}`);
|
|
10
|
+
}
|
|
11
|
+
async sendBatchedUpdates(updates) {
|
|
12
|
+
if (updates.length === 0)
|
|
13
|
+
return;
|
|
14
|
+
const message = updates
|
|
15
|
+
.map(u => `🔄 ${u.name}: ${u.oldVersion} → ${u.newVersion}`)
|
|
16
|
+
.join('\n');
|
|
17
|
+
await this.bot.sendMessage(this.chatId, message);
|
|
18
|
+
}
|
|
19
|
+
async sendFailure(name, error) {
|
|
20
|
+
await this.bot.sendMessage(this.chatId, `⚠️ Failed to check ${name}: ${error}`);
|
|
21
|
+
}
|
|
22
|
+
async sendBatchedFailures(failures) {
|
|
23
|
+
if (failures.length === 0)
|
|
24
|
+
return;
|
|
25
|
+
const message = failures
|
|
26
|
+
.map(f => `⚠️ Failed to check ${f.name}: ${f.error}`)
|
|
27
|
+
.join('\n');
|
|
28
|
+
await this.bot.sendMessage(this.chatId, message);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/notifier.test.ts
|
|
2
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
3
|
+
import { Notifier } from './notifier.js';
|
|
4
|
+
describe('Notifier', () => {
|
|
5
|
+
let mockBot;
|
|
6
|
+
let notifier;
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
mockBot = { sendMessage: vi.fn().mockResolvedValue({}) };
|
|
9
|
+
notifier = new Notifier(mockBot, '123456789');
|
|
10
|
+
});
|
|
11
|
+
it('sends update notification with correct format', async () => {
|
|
12
|
+
await notifier.sendUpdate('Ninja', '1.11.1', '1.12.0');
|
|
13
|
+
expect(mockBot.sendMessage).toHaveBeenCalledWith('123456789', '🔄 Ninja: 1.11.1 → 1.12.0');
|
|
14
|
+
});
|
|
15
|
+
it('sends batched updates as single message', async () => {
|
|
16
|
+
const updates = [
|
|
17
|
+
{ name: 'Ninja', oldVersion: '1.11.1', newVersion: '1.12.0' },
|
|
18
|
+
{ name: 'Git', oldVersion: '2.43.0', newVersion: '2.44.0' }
|
|
19
|
+
];
|
|
20
|
+
await notifier.sendBatchedUpdates(updates);
|
|
21
|
+
expect(mockBot.sendMessage).toHaveBeenCalledTimes(1);
|
|
22
|
+
expect(mockBot.sendMessage).toHaveBeenCalledWith('123456789', '🔄 Ninja: 1.11.1 → 1.12.0\n🔄 Git: 2.43.0 → 2.44.0');
|
|
23
|
+
});
|
|
24
|
+
it('sends failure notification', async () => {
|
|
25
|
+
await notifier.sendFailure('CMake', 'Request timeout');
|
|
26
|
+
expect(mockBot.sendMessage).toHaveBeenCalledWith('123456789', '⚠️ Failed to check CMake: Request timeout');
|
|
27
|
+
});
|
|
28
|
+
it('sends batched failures as single message', async () => {
|
|
29
|
+
const failures = [
|
|
30
|
+
{ name: 'CMake', error: 'Timeout' },
|
|
31
|
+
{ name: 'VSCode', error: 'Connection refused' }
|
|
32
|
+
];
|
|
33
|
+
await notifier.sendBatchedFailures(failures);
|
|
34
|
+
expect(mockBot.sendMessage).toHaveBeenCalledTimes(1);
|
|
35
|
+
expect(mockBot.sendMessage).toHaveBeenCalledWith('123456789', '⚠️ Failed to check CMake: Timeout\n⚠️ Failed to check VSCode: Connection refused');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface StorageState {
|
|
2
|
+
lastCheck: string | null;
|
|
3
|
+
versions: Record<string, string>;
|
|
4
|
+
}
|
|
5
|
+
export declare class Storage {
|
|
6
|
+
private filePath;
|
|
7
|
+
private state;
|
|
8
|
+
constructor(filePath: string);
|
|
9
|
+
private ensureLoaded;
|
|
10
|
+
load(): StorageState;
|
|
11
|
+
save(state: StorageState): void;
|
|
12
|
+
getVersion(toolName: string): string | null;
|
|
13
|
+
setVersion(toolName: string, version: string): void;
|
|
14
|
+
}
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// src/storage.ts
|
|
2
|
+
import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
|
|
3
|
+
export class Storage {
|
|
4
|
+
filePath;
|
|
5
|
+
state = null;
|
|
6
|
+
constructor(filePath) {
|
|
7
|
+
this.filePath = filePath;
|
|
8
|
+
}
|
|
9
|
+
ensureLoaded() {
|
|
10
|
+
if (!this.state) {
|
|
11
|
+
this.state = this.load();
|
|
12
|
+
}
|
|
13
|
+
return this.state;
|
|
14
|
+
}
|
|
15
|
+
load() {
|
|
16
|
+
if (!existsSync(this.filePath)) {
|
|
17
|
+
return { lastCheck: null, versions: {} };
|
|
18
|
+
}
|
|
19
|
+
const content = readFileSync(this.filePath, 'utf-8');
|
|
20
|
+
return JSON.parse(content);
|
|
21
|
+
}
|
|
22
|
+
save(state) {
|
|
23
|
+
const tempPath = `${this.filePath}.tmp`;
|
|
24
|
+
writeFileSync(tempPath, JSON.stringify(state, null, 2));
|
|
25
|
+
renameSync(tempPath, this.filePath);
|
|
26
|
+
}
|
|
27
|
+
getVersion(toolName) {
|
|
28
|
+
const state = this.ensureLoaded();
|
|
29
|
+
return state.versions[toolName] ?? null;
|
|
30
|
+
}
|
|
31
|
+
setVersion(toolName, version) {
|
|
32
|
+
const state = this.ensureLoaded();
|
|
33
|
+
state.versions[toolName] = version;
|
|
34
|
+
state.lastCheck = new Date().toISOString();
|
|
35
|
+
this.save(state);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/storage.test.ts
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import { Storage } from './storage.js';
|
|
4
|
+
import { writeFileSync, mkdirSync, rmSync } from 'fs';
|
|
5
|
+
describe('Storage', () => {
|
|
6
|
+
const testDir = './test-data';
|
|
7
|
+
const testPath = `${testDir}/versions.json`;
|
|
8
|
+
let storage;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mkdirSync(testDir, { recursive: true });
|
|
11
|
+
storage = new Storage(testPath);
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
15
|
+
});
|
|
16
|
+
it('returns empty state when file does not exist', () => {
|
|
17
|
+
const state = storage.load();
|
|
18
|
+
expect(state).toEqual({ lastCheck: null, versions: {} });
|
|
19
|
+
});
|
|
20
|
+
it('loads existing state from file', () => {
|
|
21
|
+
const existingState = {
|
|
22
|
+
lastCheck: '2026-01-23T10:00:00Z',
|
|
23
|
+
versions: { 'VSCode': '1.96.0' }
|
|
24
|
+
};
|
|
25
|
+
writeFileSync(testPath, JSON.stringify(existingState));
|
|
26
|
+
const state = storage.load();
|
|
27
|
+
expect(state).toEqual(existingState);
|
|
28
|
+
});
|
|
29
|
+
it('saves state to file', () => {
|
|
30
|
+
const state = {
|
|
31
|
+
lastCheck: '2026-01-23T10:00:00Z',
|
|
32
|
+
versions: { 'Ninja': '1.12.0' }
|
|
33
|
+
};
|
|
34
|
+
storage.save(state);
|
|
35
|
+
const loaded = storage.load();
|
|
36
|
+
expect(loaded).toEqual(state);
|
|
37
|
+
});
|
|
38
|
+
it('getVersion returns null for unknown tool', () => {
|
|
39
|
+
expect(storage.getVersion('Unknown')).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
it('getVersion returns stored version', () => {
|
|
42
|
+
const state = { lastCheck: null, versions: { 'Git': '2.44.0' } };
|
|
43
|
+
writeFileSync(testPath, JSON.stringify(state));
|
|
44
|
+
storage = new Storage(testPath); // reload
|
|
45
|
+
expect(storage.getVersion('Git')).toBe('2.44.0');
|
|
46
|
+
});
|
|
47
|
+
it('setVersion updates and persists', () => {
|
|
48
|
+
storage.setVersion('Ninja', '1.12.0');
|
|
49
|
+
// Reload and verify
|
|
50
|
+
const newStorage = new Storage(testPath);
|
|
51
|
+
expect(newStorage.getVersion('Ninja')).toBe('1.12.0');
|
|
52
|
+
});
|
|
53
|
+
});
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface ToolConfig {
|
|
2
|
+
name: string;
|
|
3
|
+
type: 'github' | 'npm' | 'vscode-marketplace' | 'custom';
|
|
4
|
+
repo?: string;
|
|
5
|
+
package?: string;
|
|
6
|
+
extensionId?: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
fallbackUrl?: string;
|
|
9
|
+
customFetcher?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface Config {
|
|
12
|
+
checkIntervalHours: number;
|
|
13
|
+
tools: ToolConfig[];
|
|
14
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lvnt/release-radar",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Monitor tool versions and notify via Telegram when updates are detected",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"release-radar": "./bin/release-radar.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"dev": "tsx src/index.ts",
|
|
14
|
+
"test": "vitest run",
|
|
15
|
+
"test:watch": "vitest",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"release",
|
|
20
|
+
"monitor",
|
|
21
|
+
"version",
|
|
22
|
+
"telegram",
|
|
23
|
+
"notification",
|
|
24
|
+
"github",
|
|
25
|
+
"npm",
|
|
26
|
+
"vscode"
|
|
27
|
+
],
|
|
28
|
+
"author": "lvnt",
|
|
29
|
+
"license": "ISC",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/lvntbkdmr/release-radar.git"
|
|
33
|
+
},
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/lvntbkdmr/release-radar/issues"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/lvntbkdmr/release-radar#readme",
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"bin",
|
|
41
|
+
"config",
|
|
42
|
+
".env.example"
|
|
43
|
+
],
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"dotenv": "^17.2.3",
|
|
46
|
+
"node-cron": "^4.2.1",
|
|
47
|
+
"node-telegram-bot-api": "^0.67.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^25.0.10",
|
|
51
|
+
"@types/node-cron": "^3.0.11",
|
|
52
|
+
"@types/node-telegram-bot-api": "^0.64.13",
|
|
53
|
+
"tsx": "^4.21.0",
|
|
54
|
+
"typescript": "^5.9.3",
|
|
55
|
+
"vitest": "^4.0.18"
|
|
56
|
+
}
|
|
57
|
+
}
|