@lvnt/release-radar 1.8.2 → 1.9.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
@@ -113,10 +113,12 @@ TELEGRAM_CHAT_ID=your_chat_id_here
113
113
 
114
114
  ### Tools Configuration
115
115
 
116
- Edit `config/tools.json` to add/remove tools:
116
+ Edit `config/tools.json` to add/remove tools and configure scheduling:
117
117
 
118
118
  ```json
119
119
  {
120
+ "scheduleMode": "daily",
121
+ "dailyCheckTime": "06:00",
120
122
  "checkIntervalHours": 6,
121
123
  "tools": [
122
124
  {
@@ -128,6 +130,14 @@ Edit `config/tools.json` to add/remove tools:
128
130
  }
129
131
  ```
130
132
 
133
+ #### Schedule Options
134
+
135
+ | Field | Values | Description |
136
+ |-------|--------|-------------|
137
+ | `scheduleMode` | `"daily"` or `"interval"` | Check once per day or every N hours |
138
+ | `dailyCheckTime` | `"HH:MM"` | Time for daily check (24-hour format, e.g., `"06:00"`) |
139
+ | `checkIntervalHours` | `1-24` | Hours between checks (interval mode) |
140
+
131
141
  #### Tool Types
132
142
 
133
143
  | Type | Required Fields | Description |
@@ -169,8 +179,11 @@ Placeholders:
169
179
  |---------|-------------|
170
180
  | `/check` | Manually trigger version check (auto-publishes CLI if updates found) |
171
181
  | `/status` | Show all tracked versions + last/next check times |
172
- | `/interval` | Show current check interval |
173
- | `/setinterval <hours>` | Set check interval (1-24 hours) |
182
+ | `/schedule` | Show current schedule mode and next check time |
183
+ | `/settime <HH:MM>` | Set daily check time (e.g., `/settime 06:00`) and switch to daily mode |
184
+ | `/setmode <daily\|interval>` | Switch between daily and interval modes |
185
+ | `/interval` | Show current check interval (interval mode) |
186
+ | `/setinterval <hours>` | Set check interval (1-24 hours) and switch to interval mode |
174
187
  | `/generate` | Generate versions.json file locally |
175
188
  | `/clipreview` | Preview tools/versions that will be included in CLI |
176
189
  | `/publishcli` | Manually publish CLI with current tracked versions |
package/cli/src/ui.ts CHANGED
@@ -132,21 +132,28 @@ export interface TableRow {
132
132
  downloadedVersion: string;
133
133
  status: 'new' | 'update' | 'current';
134
134
  type: 'npm' | 'download';
135
+ category: 'tool' | 'vscode-extension';
135
136
  }
136
137
 
137
- export function renderTable(rows: TableRow[]): void {
138
- // Calculate dynamic column widths
139
- const colWidths = {
138
+ interface ColWidths {
139
+ tool: number;
140
+ latest: number;
141
+ downloaded: number;
142
+ status: number;
143
+ type: number;
144
+ }
145
+
146
+ function calculateColWidths(rows: TableRow[]): ColWidths {
147
+ return {
140
148
  tool: Math.max(4, ...rows.map(r => r.displayName.length)) + 2,
141
149
  latest: Math.max(6, ...rows.map(r => r.version.length)) + 2,
142
150
  downloaded: Math.max(10, ...rows.map(r => r.downloadedVersion.length)) + 2,
143
151
  status: 8,
144
152
  type: 4,
145
153
  };
154
+ }
146
155
 
147
- const totalWidth = colWidths.tool + colWidths.latest + colWidths.downloaded + colWidths.status + colWidths.type + 2;
148
-
149
- // Header
156
+ function renderTableHeader(colWidths: ColWidths): void {
150
157
  console.log(chalk.bold(
151
158
  ' ' +
152
159
  'Tool'.padEnd(colWidths.tool) +
@@ -155,40 +162,76 @@ export function renderTable(rows: TableRow[]): void {
155
162
  'Status'.padEnd(colWidths.status) +
156
163
  'Type'
157
164
  ));
165
+ const totalWidth = colWidths.tool + colWidths.latest + colWidths.downloaded + colWidths.status + colWidths.type + 2;
166
+ console.log(chalk.gray('─'.repeat(totalWidth)));
167
+ }
168
+
169
+ function renderTableRow(row: TableRow, colWidths: ColWidths): void {
170
+ let statusText: string;
171
+ let statusColored: string;
172
+ switch (row.status) {
173
+ case 'new':
174
+ statusText = 'NEW'.padEnd(colWidths.status);
175
+ statusColored = chalk.blue(statusText);
176
+ break;
177
+ case 'update':
178
+ statusText = 'UPDATE'.padEnd(colWidths.status);
179
+ statusColored = chalk.yellow(statusText);
180
+ break;
181
+ case 'current':
182
+ statusText = '✓'.padEnd(colWidths.status);
183
+ statusColored = chalk.green(statusText);
184
+ break;
185
+ }
186
+ const typeStr = row.type === 'npm' ? chalk.magenta('npm') : chalk.cyan('wget');
187
+
188
+ console.log(
189
+ ' ' +
190
+ row.displayName.padEnd(colWidths.tool) +
191
+ row.version.padEnd(colWidths.latest) +
192
+ row.downloadedVersion.padEnd(colWidths.downloaded) +
193
+ statusColored +
194
+ typeStr
195
+ );
196
+ }
197
+
198
+ function renderGroupHeader(title: string, colWidths: ColWidths): void {
199
+ const totalWidth = colWidths.tool + colWidths.latest + colWidths.downloaded + colWidths.status + colWidths.type + 2;
200
+ console.log('');
201
+ console.log(chalk.bold.underline(` ${title}`));
158
202
  console.log(chalk.gray('─'.repeat(totalWidth)));
203
+ }
204
+
205
+ export function renderTable(rows: TableRow[]): void {
206
+ const colWidths = calculateColWidths(rows);
159
207
 
160
- // Rows
161
- for (const row of rows) {
162
- // Pad plain text BEFORE applying colors (chalk adds invisible ANSI codes)
163
- let statusText: string;
164
- let statusColored: string;
165
- switch (row.status) {
166
- case 'new':
167
- statusText = 'NEW'.padEnd(colWidths.status);
168
- statusColored = chalk.blue(statusText);
169
- break;
170
- case 'update':
171
- statusText = 'UPDATE'.padEnd(colWidths.status);
172
- statusColored = chalk.yellow(statusText);
173
- break;
174
- case 'current':
175
- statusText = '✓'.padEnd(colWidths.status);
176
- statusColored = chalk.green(statusText);
177
- break;
208
+ // Group rows by category
209
+ const tools = rows.filter(r => r.category === 'tool');
210
+ const extensions = rows.filter(r => r.category === 'vscode-extension');
211
+
212
+ // Render tools first
213
+ if (tools.length > 0) {
214
+ renderTableHeader(colWidths);
215
+ for (const row of tools) {
216
+ renderTableRow(row, colWidths);
217
+ }
218
+ }
219
+
220
+ // Render VSCode extensions as a separate group
221
+ if (extensions.length > 0) {
222
+ renderGroupHeader('VSCode Extensions', colWidths);
223
+ for (const row of extensions) {
224
+ renderTableRow(row, colWidths);
178
225
  }
179
- const typeStr = row.type === 'npm' ? chalk.magenta('npm') : chalk.cyan('wget');
180
-
181
- console.log(
182
- ' ' +
183
- row.displayName.padEnd(colWidths.tool) +
184
- row.version.padEnd(colWidths.latest) +
185
- row.downloadedVersion.padEnd(colWidths.downloaded) +
186
- statusColored +
187
- typeStr
188
- );
189
226
  }
190
227
  }
191
228
 
229
+ function isVscodeExtension(tool: VersionsJsonTool): boolean {
230
+ if (isNpmTool(tool)) return false;
231
+ // Check if filename ends with .vsix
232
+ return tool.filename.toLowerCase().endsWith('.vsix');
233
+ }
234
+
192
235
  export async function promptToolSelection(
193
236
  tools: VersionsJsonTool[],
194
237
  downloaded: DownloadedState,
@@ -199,24 +242,30 @@ export async function promptToolSelection(
199
242
  console.log(chalk.bold(`\nrelease-radar-cli`));
200
243
  console.log(chalk.gray(`Last updated: ${new Date(generatedAt).toLocaleString()}\n`));
201
244
 
202
- // Convert to table rows and display
203
- const rows: TableRow[] = choices.map(choice => ({
204
- displayName: choice.displayName,
205
- version: choice.version,
206
- downloadedVersion: choice.downloadedVersion ?? '-',
207
- status: choice.status,
208
- type: choice.type === 'npm' ? 'npm' : 'download',
245
+ // Convert to table rows with category and display
246
+ const rows: TableRow[] = tools.map((tool, i) => ({
247
+ displayName: choices[i].displayName,
248
+ version: choices[i].version,
249
+ downloadedVersion: choices[i].downloadedVersion ?? '-',
250
+ status: choices[i].status,
251
+ type: choices[i].type === 'npm' ? 'npm' : 'download',
252
+ category: isVscodeExtension(tool) ? 'vscode-extension' : 'tool',
209
253
  }));
210
254
 
211
255
  renderTable(rows);
212
256
  console.log('');
213
257
 
258
+ // Sort choices to match displayed order (tools first, then extensions)
259
+ const toolChoices = choices.filter((_, i) => !isVscodeExtension(tools[i]));
260
+ const extensionChoices = choices.filter((_, i) => isVscodeExtension(tools[i]));
261
+ const sortedChoices = [...toolChoices, ...extensionChoices];
262
+
214
263
  const { selected } = await inquirer.prompt([
215
264
  {
216
265
  type: 'checkbox',
217
266
  name: 'selected',
218
267
  message: 'Select tools to download:',
219
- choices: choices.map((choice) => ({
268
+ choices: sortedChoices.map((choice) => ({
220
269
  name: `${choice.displayName} ${choice.version}`,
221
270
  value: choice,
222
271
  checked: choice.status !== 'current',
package/config/tools.json CHANGED
@@ -1,5 +1,7 @@
1
1
  {
2
- "checkIntervalHours": 1,
2
+ "checkIntervalHours": 6,
3
+ "scheduleMode": "daily",
4
+ "dailyCheckTime": "06:00",
3
5
  "tools": [
4
6
  {
5
7
  "name": "VSCode",
@@ -4,14 +4,34 @@ export interface MirrorResult {
4
4
  downloadUrl?: string;
5
5
  error?: string;
6
6
  }
7
+ export interface MirrorItem {
8
+ toolName: string;
9
+ version: string;
10
+ config: MirrorConfig;
11
+ filenameTemplate: string;
12
+ }
13
+ export interface BatchMirrorResult {
14
+ tag: string;
15
+ results: Map<string, MirrorResult>;
16
+ }
7
17
  export declare class AssetMirror {
8
18
  private repo;
19
+ /**
20
+ * Mirror a single tool (legacy method, still used for /mirror command)
21
+ */
9
22
  mirror(toolName: string, version: string, config: MirrorConfig, filenameTemplate: string): Promise<MirrorResult>;
23
+ /**
24
+ * Mirror multiple tools into a single batch release
25
+ */
26
+ mirrorBatch(items: MirrorItem[]): Promise<BatchMirrorResult>;
10
27
  buildTag(toolName: string, version: string): string;
28
+ buildBatchTag(): string;
29
+ private findExistingAsset;
11
30
  releaseExists(tag: string): Promise<boolean>;
12
31
  private applyVersion;
13
32
  private getSourceUrl;
14
33
  private getMarketplaceVsixUrl;
15
34
  private downloadFile;
16
35
  private createRelease;
36
+ private createBatchRelease;
17
37
  }
@@ -5,6 +5,9 @@ import { tmpdir } from 'os';
5
5
  import { join } from 'path';
6
6
  export class AssetMirror {
7
7
  repo = 'lvntbkdmr/apps';
8
+ /**
9
+ * Mirror a single tool (legacy method, still used for /mirror command)
10
+ */
8
11
  async mirror(toolName, version, config, filenameTemplate) {
9
12
  const tag = this.buildTag(toolName, version);
10
13
  const filename = this.applyVersion(filenameTemplate, version);
@@ -38,10 +41,86 @@ export class AssetMirror {
38
41
  return { success: false, error: message };
39
42
  }
40
43
  }
44
+ /**
45
+ * Mirror multiple tools into a single batch release
46
+ */
47
+ async mirrorBatch(items) {
48
+ const tag = this.buildBatchTag();
49
+ const results = new Map();
50
+ const downloadedFiles = [];
51
+ console.log(`[AssetMirror] Starting batch mirror for ${items.length} items, tag: ${tag}`);
52
+ // Download all files first
53
+ for (const item of items) {
54
+ const filename = this.applyVersion(item.filenameTemplate, item.version);
55
+ const downloadUrl = `github.com/${this.repo}/releases/download/${tag}/${filename}`;
56
+ try {
57
+ // Check if this specific file already exists in any release
58
+ const existingUrl = await this.findExistingAsset(item.toolName, item.version, filename);
59
+ if (existingUrl) {
60
+ console.log(`[AssetMirror] ${item.toolName} v${item.version} already mirrored, skipping`);
61
+ results.set(item.toolName, { success: true, downloadUrl: existingUrl });
62
+ continue;
63
+ }
64
+ console.log(`[AssetMirror] Getting source URL for ${item.toolName} v${item.version}...`);
65
+ const sourceUrl = await this.getSourceUrl(item.config, item.version);
66
+ const tempPath = join(tmpdir(), filename);
67
+ console.log(`[AssetMirror] Downloading to ${tempPath}...`);
68
+ await this.downloadFile(sourceUrl, tempPath);
69
+ downloadedFiles.push({ path: tempPath, filename });
70
+ results.set(item.toolName, { success: true, downloadUrl });
71
+ }
72
+ catch (error) {
73
+ const message = error instanceof Error ? error.message : String(error);
74
+ console.error(`[AssetMirror] Failed to download ${item.toolName}: ${message}`);
75
+ results.set(item.toolName, { success: false, error: message });
76
+ }
77
+ }
78
+ // Create single release with all downloaded files
79
+ if (downloadedFiles.length > 0) {
80
+ try {
81
+ console.log(`[AssetMirror] Creating batch release ${tag} with ${downloadedFiles.length} assets...`);
82
+ await this.createBatchRelease(tag, downloadedFiles, items);
83
+ console.log(`[AssetMirror] Batch release created successfully`);
84
+ }
85
+ catch (error) {
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ console.error(`[AssetMirror] Failed to create batch release: ${message}`);
88
+ // Mark all as failed if release creation fails
89
+ for (const file of downloadedFiles) {
90
+ const toolName = items.find(i => this.applyVersion(i.filenameTemplate, i.version) === file.filename)?.toolName;
91
+ if (toolName) {
92
+ results.set(toolName, { success: false, error: message });
93
+ }
94
+ }
95
+ }
96
+ // Cleanup temp files
97
+ for (const file of downloadedFiles) {
98
+ if (existsSync(file.path)) {
99
+ unlinkSync(file.path);
100
+ }
101
+ }
102
+ }
103
+ return { tag, results };
104
+ }
41
105
  buildTag(toolName, version) {
42
106
  const kebab = toolName.toLowerCase().replace(/\s+/g, '-');
43
107
  return `${kebab}-v${version}`;
44
108
  }
109
+ buildBatchTag() {
110
+ const now = new Date();
111
+ const date = now.toISOString().split('T')[0]; // YYYY-MM-DD
112
+ const time = now.toTimeString().split(' ')[0].replace(/:/g, ''); // HHMMSS
113
+ return `batch-${date}-${time}`;
114
+ }
115
+ async findExistingAsset(toolName, version, filename) {
116
+ // Check legacy per-tool release first
117
+ const legacyTag = this.buildTag(toolName, version);
118
+ if (await this.releaseExists(legacyTag)) {
119
+ return `github.com/${this.repo}/releases/download/${legacyTag}/${filename}`;
120
+ }
121
+ // Could also search batch releases, but for simplicity we skip if not in legacy
122
+ return null;
123
+ }
45
124
  async releaseExists(tag) {
46
125
  try {
47
126
  execSync(`gh release view ${tag} --repo ${this.repo}`, {
@@ -118,4 +197,23 @@ export class AssetMirror {
118
197
  execSync(`gh release create "${tag}" "${filePath}#${filename}" ` +
119
198
  `--repo ${this.repo} --title "${title}" --notes "${notes}"`, { encoding: 'utf-8', timeout: 300000 });
120
199
  }
200
+ async createBatchRelease(tag, files, items) {
201
+ const date = new Date().toLocaleDateString('en-US', {
202
+ year: 'numeric',
203
+ month: 'long',
204
+ day: 'numeric',
205
+ });
206
+ const title = `Updates ${date}`;
207
+ // Build release notes listing all tools
208
+ const toolsList = items
209
+ .filter(item => files.some(f => f.filename === this.applyVersion(item.filenameTemplate, item.version)))
210
+ .map(item => `- ${item.toolName} ${item.version}`)
211
+ .join('\n');
212
+ const notes = `Mirrored for Nexus proxy access.\n\n**Included:**\n${toolsList}`;
213
+ // Build file arguments for gh release create
214
+ const fileArgs = files.map(f => `"${f.path}#${f.filename}"`).join(' ');
215
+ execSync(`gh release create "${tag}" ${fileArgs} ` +
216
+ `--repo ${this.repo} --title "${title}" --notes "${notes}"`, { encoding: 'utf-8', timeout: 600000 } // 10 minutes for multiple uploads
217
+ );
218
+ }
121
219
  }
package/dist/checker.d.ts CHANGED
@@ -13,5 +13,5 @@ export declare class Checker {
13
13
  hasUpdates: boolean;
14
14
  updateCount: number;
15
15
  }>;
16
- private mirrorIfConfigured;
16
+ private getMirrorItem;
17
17
  }
package/dist/checker.js CHANGED
@@ -15,6 +15,7 @@ export class Checker {
15
15
  async checkAll() {
16
16
  const updates = [];
17
17
  const failures = [];
18
+ const mirrorItems = [];
18
19
  for (const tool of this.tools) {
19
20
  try {
20
21
  const newVersion = await fetchVersion(tool);
@@ -26,8 +27,11 @@ export class Checker {
26
27
  else if (oldVersion !== newVersion) {
27
28
  updates.push({ name: tool.name, oldVersion, newVersion });
28
29
  this.storage.setVersion(tool.name, newVersion);
29
- // Mirror asset if configured
30
- await this.mirrorIfConfigured(tool.name, newVersion);
30
+ // Collect mirror items for batch processing
31
+ const mirrorItem = this.getMirrorItem(tool.name, newVersion);
32
+ if (mirrorItem) {
33
+ mirrorItems.push(mirrorItem);
34
+ }
31
35
  }
32
36
  }
33
37
  catch (error) {
@@ -35,22 +39,34 @@ export class Checker {
35
39
  failures.push({ name: tool.name, error: message });
36
40
  }
37
41
  }
42
+ // Batch mirror all updated assets
43
+ if (mirrorItems.length > 0 && this.assetMirror) {
44
+ const batchResult = await this.assetMirror.mirrorBatch(mirrorItems);
45
+ // Store mirror URLs for successful items
46
+ for (const [toolName, result] of batchResult.results) {
47
+ if (result.success && result.downloadUrl) {
48
+ this.storage.setMirrorUrl(toolName, result.downloadUrl);
49
+ }
50
+ }
51
+ }
38
52
  await this.notifier.sendBatchedUpdates(updates);
39
53
  await this.notifier.sendBatchedFailures(failures);
40
54
  return { hasUpdates: updates.length > 0, updateCount: updates.length };
41
55
  }
42
- async mirrorIfConfigured(toolName, version) {
43
- if (!this.assetMirror || !this.downloadsConfig)
44
- return;
56
+ getMirrorItem(toolName, version) {
57
+ if (!this.downloadsConfig)
58
+ return null;
45
59
  const downloadConfig = this.downloadsConfig[toolName];
46
60
  if (!downloadConfig || downloadConfig.type === 'npm')
47
- return;
61
+ return null;
48
62
  const urlConfig = downloadConfig;
49
63
  if (!urlConfig.mirror)
50
- return;
51
- const result = await this.assetMirror.mirror(toolName, version, urlConfig.mirror, urlConfig.filename);
52
- if (result.success && result.downloadUrl) {
53
- this.storage.setMirrorUrl(toolName, result.downloadUrl);
54
- }
64
+ return null;
65
+ return {
66
+ toolName,
67
+ version,
68
+ config: urlConfig.mirror,
69
+ filenameTemplate: urlConfig.filename,
70
+ };
55
71
  }
56
72
  }
@@ -83,7 +83,10 @@ describe('Checker', () => {
83
83
  let downloadsConfig;
84
84
  beforeEach(() => {
85
85
  mockAssetMirror = {
86
- mirror: vi.fn().mockResolvedValue({ success: true, downloadUrl: 'github.com/test/url' })
86
+ mirrorBatch: vi.fn().mockResolvedValue({
87
+ tag: 'batch-2026-01-29-120000',
88
+ results: new Map([['VSCode', { success: true, downloadUrl: 'github.com/test/url' }]])
89
+ })
87
90
  };
88
91
  downloadsConfig = {
89
92
  'VSCode': {
@@ -106,7 +109,14 @@ describe('Checker', () => {
106
109
  mockStorage.getVersion.mockReturnValue('1.95.0');
107
110
  vi.mocked(fetchVersion).mockResolvedValue('1.96.0');
108
111
  await checkerWithMirror.checkAll();
109
- expect(mockAssetMirror.mirror).toHaveBeenCalledWith('VSCode', '1.96.0', { sourceUrl: 'https://update.code.visualstudio.com/latest/win32-x64/stable' }, 'VSCode-{{VERSION}}-win-x64.msi');
112
+ expect(mockAssetMirror.mirrorBatch).toHaveBeenCalledWith([
113
+ {
114
+ toolName: 'VSCode',
115
+ version: '1.96.0',
116
+ config: { sourceUrl: 'https://update.code.visualstudio.com/latest/win32-x64/stable' },
117
+ filenameTemplate: 'VSCode-{{VERSION}}-win-x64.msi'
118
+ }
119
+ ]);
110
120
  expect(mockStorage.setMirrorUrl).toHaveBeenCalledWith('VSCode', 'github.com/test/url');
111
121
  });
112
122
  it('does not mirror when tool has no mirror config', async () => {
@@ -115,7 +125,8 @@ describe('Checker', () => {
115
125
  mockStorage.getVersion.mockReturnValue('1.11.0');
116
126
  vi.mocked(fetchVersion).mockResolvedValue('1.12.0');
117
127
  await checkerWithMirror.checkAll();
118
- expect(mockAssetMirror.mirror).not.toHaveBeenCalled();
128
+ // mirrorBatch should not be called when no items have mirror config
129
+ expect(mockAssetMirror.mirrorBatch).not.toHaveBeenCalled();
119
130
  });
120
131
  it('does not mirror when version unchanged', async () => {
121
132
  const vscodeTool = { name: 'VSCode', type: 'custom', customFetcher: 'vscode' };
@@ -123,11 +134,14 @@ describe('Checker', () => {
123
134
  mockStorage.getVersion.mockReturnValue('1.96.0');
124
135
  vi.mocked(fetchVersion).mockResolvedValue('1.96.0');
125
136
  await checkerWithMirror.checkAll();
126
- expect(mockAssetMirror.mirror).not.toHaveBeenCalled();
137
+ expect(mockAssetMirror.mirrorBatch).not.toHaveBeenCalled();
127
138
  });
128
139
  it('continues if mirror fails', async () => {
129
140
  const vscodeTool = { name: 'VSCode', type: 'custom', customFetcher: 'vscode' };
130
- mockAssetMirror.mirror.mockResolvedValue({ success: false, error: 'network error' });
141
+ mockAssetMirror.mirrorBatch.mockResolvedValue({
142
+ tag: 'batch-2026-01-29-120000',
143
+ results: new Map([['VSCode', { success: false, error: 'network error' }]])
144
+ });
131
145
  const checkerWithMirror = new Checker([vscodeTool], mockStorage, mockNotifier, mockAssetMirror, downloadsConfig);
132
146
  mockStorage.getVersion.mockReturnValue('1.95.0');
133
147
  vi.mocked(fetchVersion).mockResolvedValue('1.96.0');
package/dist/index.js CHANGED
@@ -108,7 +108,18 @@ const checker = new Checker(configData.tools, storage, notifier, assetMirror, do
108
108
  let scheduledTask = null;
109
109
  let lastCheckTime = null;
110
110
  let nextCheckTime = null;
111
- function calculateNextCheckTime(intervalHours) {
111
+ // Parse HH:MM time string
112
+ function parseTime(timeStr) {
113
+ const match = timeStr.match(/^(\d{1,2}):(\d{2})$/);
114
+ if (!match)
115
+ return null;
116
+ const hour = parseInt(match[1], 10);
117
+ const minute = parseInt(match[2], 10);
118
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59)
119
+ return null;
120
+ return { hour, minute };
121
+ }
122
+ function calculateNextCheckTimeInterval(intervalHours) {
112
123
  const now = new Date();
113
124
  const next = new Date(now);
114
125
  next.setMinutes(0, 0, 0);
@@ -118,28 +129,67 @@ function calculateNextCheckTime(intervalHours) {
118
129
  }
119
130
  return next;
120
131
  }
121
- function scheduleChecks(intervalHours) {
132
+ function calculateNextCheckTimeDaily(timeStr) {
133
+ const parsed = parseTime(timeStr);
134
+ if (!parsed) {
135
+ // Fallback to 6am if invalid
136
+ return calculateNextCheckTimeDaily('06:00');
137
+ }
138
+ const now = new Date();
139
+ const next = new Date(now);
140
+ next.setHours(parsed.hour, parsed.minute, 0, 0);
141
+ // If the time has passed today, schedule for tomorrow
142
+ if (next <= now) {
143
+ next.setDate(next.getDate() + 1);
144
+ }
145
+ return next;
146
+ }
147
+ function calculateNextCheckTime() {
148
+ const mode = configData.scheduleMode || 'interval';
149
+ if (mode === 'daily') {
150
+ return calculateNextCheckTimeDaily(configData.dailyCheckTime || '06:00');
151
+ }
152
+ return calculateNextCheckTimeInterval(configData.checkIntervalHours);
153
+ }
154
+ async function runScheduledCheck() {
155
+ console.log(`[${new Date().toISOString()}] Running scheduled check`);
156
+ lastCheckTime = new Date();
157
+ const result = await checker.checkAll();
158
+ nextCheckTime = calculateNextCheckTime();
159
+ // Auto-publish CLI if updates were detected
160
+ if (result.hasUpdates && cliPublisher.isConfigured()) {
161
+ const state = storage.load();
162
+ const mirrorUrls = storage.getAllMirrorUrls();
163
+ const publishResult = await cliPublisher.publish(state.versions, mirrorUrls);
164
+ if (publishResult.success) {
165
+ await bot.sendMessage(validatedChatId, `📦 CLI published: v${publishResult.version}`);
166
+ }
167
+ }
168
+ }
169
+ function scheduleChecks() {
122
170
  if (scheduledTask) {
123
171
  scheduledTask.stop();
124
172
  }
125
- nextCheckTime = calculateNextCheckTime(intervalHours);
126
- const cronExpression = `0 */${intervalHours} * * *`;
127
- scheduledTask = cron.schedule(cronExpression, async () => {
128
- console.log(`[${new Date().toISOString()}] Running scheduled check`);
129
- lastCheckTime = new Date();
130
- const result = await checker.checkAll();
131
- nextCheckTime = calculateNextCheckTime(intervalHours);
132
- // Auto-publish CLI if updates were detected
133
- if (result.hasUpdates && cliPublisher.isConfigured()) {
134
- const state = storage.load();
135
- const mirrorUrls = storage.getAllMirrorUrls();
136
- const publishResult = await cliPublisher.publish(state.versions, mirrorUrls);
137
- if (publishResult.success) {
138
- await bot.sendMessage(validatedChatId, `📦 CLI published: v${publishResult.version}`);
139
- }
140
- }
141
- });
142
- console.log(`Scheduled checks every ${intervalHours} hours`);
173
+ const mode = configData.scheduleMode || 'interval';
174
+ let cronExpression;
175
+ if (mode === 'daily') {
176
+ const timeStr = configData.dailyCheckTime || '06:00';
177
+ const parsed = parseTime(timeStr);
178
+ const hour = parsed?.hour ?? 6;
179
+ const minute = parsed?.minute ?? 0;
180
+ cronExpression = `${minute} ${hour} * * *`;
181
+ console.log(`Scheduled daily check at ${timeStr}`);
182
+ }
183
+ else {
184
+ const intervalHours = configData.checkIntervalHours;
185
+ cronExpression = `0 */${intervalHours} * * *`;
186
+ console.log(`Scheduled checks every ${intervalHours} hours`);
187
+ }
188
+ nextCheckTime = calculateNextCheckTime();
189
+ scheduledTask = cron.schedule(cronExpression, runScheduledCheck);
190
+ }
191
+ function saveConfig() {
192
+ writeFileSync(CONFIG_PATH, JSON.stringify(configData, null, 2));
143
193
  }
144
194
  // Bot commands
145
195
  bot.onText(/\/check/, async (msg) => {
@@ -148,7 +198,7 @@ bot.onText(/\/check/, async (msg) => {
148
198
  await bot.sendMessage(validatedChatId, 'Checking for updates...');
149
199
  lastCheckTime = new Date();
150
200
  const result = await checker.checkAll();
151
- nextCheckTime = calculateNextCheckTime(configData.checkIntervalHours);
201
+ nextCheckTime = calculateNextCheckTime();
152
202
  // Auto-publish CLI if updates were detected
153
203
  if (result.hasUpdates && cliPublisher.isConfigured()) {
154
204
  const state = storage.load();
@@ -213,12 +263,84 @@ bot.onText(/\/setinterval(?:\s+(\d+))?/, async (msg, match) => {
213
263
  await bot.sendMessage(validatedChatId, 'Interval must be between 1 and 24 hours');
214
264
  return;
215
265
  }
216
- // Update config
266
+ // Update config (also switch to interval mode)
217
267
  configData.checkIntervalHours = hours;
218
- writeFileSync(CONFIG_PATH, JSON.stringify(configData, null, 2));
268
+ configData.scheduleMode = 'interval';
269
+ saveConfig();
219
270
  // Reschedule
220
- scheduleChecks(hours);
221
- await bot.sendMessage(validatedChatId, `Check interval updated to every ${hours} hours`);
271
+ scheduleChecks();
272
+ await bot.sendMessage(validatedChatId, `Check interval updated to every ${hours} hours (interval mode)`);
273
+ });
274
+ // /schedule - show current schedule configuration
275
+ bot.onText(/\/schedule$/, async (msg) => {
276
+ if (msg.chat.id.toString() !== validatedChatId)
277
+ return;
278
+ const mode = configData.scheduleMode || 'interval';
279
+ let message;
280
+ if (mode === 'daily') {
281
+ const time = configData.dailyCheckTime || '06:00';
282
+ message = `📅 Schedule: Daily at ${time}`;
283
+ }
284
+ else {
285
+ message = `🔄 Schedule: Every ${configData.checkIntervalHours} hours`;
286
+ }
287
+ // Add next check info
288
+ if (nextCheckTime) {
289
+ const mins = Math.round((nextCheckTime.getTime() - Date.now()) / 60000);
290
+ if (mins > 0) {
291
+ const hours = Math.floor(mins / 60);
292
+ const remainingMins = mins % 60;
293
+ message += `\nNext check: in ${hours > 0 ? hours + 'h ' : ''}${remainingMins}m`;
294
+ }
295
+ else {
296
+ message += '\nNext check: soon';
297
+ }
298
+ }
299
+ await bot.sendMessage(validatedChatId, message);
300
+ });
301
+ // /settime <HH:MM> - set daily check time
302
+ bot.onText(/\/settime(?:\s+(.+))?/, async (msg, match) => {
303
+ if (msg.chat.id.toString() !== validatedChatId)
304
+ return;
305
+ const timeStr = match?.[1]?.trim();
306
+ if (!timeStr) {
307
+ await bot.sendMessage(validatedChatId, 'Usage: /settime <HH:MM>\nExample: /settime 06:00');
308
+ return;
309
+ }
310
+ const parsed = parseTime(timeStr);
311
+ if (!parsed) {
312
+ await bot.sendMessage(validatedChatId, 'Invalid time format. Use HH:MM (24-hour), e.g., 06:00 or 18:30');
313
+ return;
314
+ }
315
+ // Update config and switch to daily mode
316
+ configData.dailyCheckTime = timeStr;
317
+ configData.scheduleMode = 'daily';
318
+ saveConfig();
319
+ // Reschedule
320
+ scheduleChecks();
321
+ await bot.sendMessage(validatedChatId, `✅ Daily check scheduled at ${timeStr}`);
322
+ });
323
+ // /setmode <daily|interval> - switch schedule mode
324
+ bot.onText(/\/setmode(?:\s+(.+))?/, async (msg, match) => {
325
+ if (msg.chat.id.toString() !== validatedChatId)
326
+ return;
327
+ const modeArg = match?.[1]?.trim().toLowerCase();
328
+ if (!modeArg || !['daily', 'interval'].includes(modeArg)) {
329
+ await bot.sendMessage(validatedChatId, 'Usage: /setmode <daily|interval>\n\n• daily - check once per day at configured time\n• interval - check every N hours');
330
+ return;
331
+ }
332
+ const newMode = modeArg;
333
+ configData.scheduleMode = newMode;
334
+ saveConfig();
335
+ // Reschedule
336
+ scheduleChecks();
337
+ if (newMode === 'daily') {
338
+ const time = configData.dailyCheckTime || '06:00';
339
+ await bot.sendMessage(validatedChatId, `📅 Switched to daily mode. Checking at ${time}\n\nUse /settime to change the time.`);
340
+ }
341
+ else {
342
+ await bot.sendMessage(validatedChatId, `🔄 Switched to interval mode. Checking every ${configData.checkIntervalHours} hours\n\nUse /setinterval to change the interval.`);
343
+ }
222
344
  });
223
345
  bot.onText(/\/generate/, async (msg) => {
224
346
  if (msg.chat.id.toString() !== validatedChatId)
@@ -321,6 +443,12 @@ bot.onText(/\/mirror(?:\s+(.+))?/, async (msg, match) => {
321
443
  }
322
444
  });
323
445
  // Start scheduled checks
324
- scheduleChecks(configData.checkIntervalHours);
325
- console.log(`ReleaseRadar started. Checking every ${configData.checkIntervalHours} hours.`);
446
+ scheduleChecks();
447
+ const mode = configData.scheduleMode || 'interval';
448
+ if (mode === 'daily') {
449
+ console.log(`ReleaseRadar started. Daily check at ${configData.dailyCheckTime || '06:00'}.`);
450
+ }
451
+ else {
452
+ console.log(`ReleaseRadar started. Checking every ${configData.checkIntervalHours} hours.`);
453
+ }
326
454
  console.log(`Tracking ${configData.tools.length} tools.`);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,85 @@
1
+ // src/mirror-and-publish.ts
2
+ // Standalone script to mirror assets and publish CLI
3
+ import { readFileSync, existsSync, mkdirSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname } from 'path';
7
+ import { Storage } from './storage.js';
8
+ import { AssetMirror } from './asset-mirror.js';
9
+ import { CliPublisher } from './cli-publisher.js';
10
+ import { fetchVersion } from './fetchers/index.js';
11
+ const __filename = fileURLToPath(import.meta.url);
12
+ const __dirname = dirname(__filename);
13
+ const PKG_ROOT = join(__dirname, '..');
14
+ // Paths
15
+ const CONFIG_PATH = join(PKG_ROOT, 'config', 'tools.json');
16
+ const DOWNLOADS_PATH = join(PKG_ROOT, 'config', 'downloads.json');
17
+ const HOME_DIR = process.env.HOME || process.env.USERPROFILE || '/tmp';
18
+ const DATA_DIR = process.env.RELEASE_RADAR_DATA_DIR || join(HOME_DIR, '.release-radar');
19
+ // Ensure data directory exists
20
+ if (!existsSync(DATA_DIR)) {
21
+ mkdirSync(DATA_DIR, { recursive: true });
22
+ }
23
+ // Load configs
24
+ const configData = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
25
+ const downloadsConfig = JSON.parse(readFileSync(DOWNLOADS_PATH, 'utf-8'));
26
+ // Initialize components
27
+ const storage = new Storage(join(DATA_DIR, 'versions.json'));
28
+ const assetMirror = new AssetMirror();
29
+ const USER_CLI_DIR = join(DATA_DIR, 'cli');
30
+ const cliPublisher = new CliPublisher(downloadsConfig, USER_CLI_DIR);
31
+ async function main() {
32
+ console.log('🔍 Checking versions and mirroring assets...\n');
33
+ const toolsToMirror = configData.tools.filter((tool) => {
34
+ const downloadConfig = downloadsConfig[tool.name];
35
+ return downloadConfig &&
36
+ downloadConfig.type !== 'npm' &&
37
+ 'mirror' in downloadConfig &&
38
+ downloadConfig.mirror;
39
+ });
40
+ console.log(`Found ${toolsToMirror.length} tools configured for mirroring`);
41
+ for (const tool of toolsToMirror) {
42
+ try {
43
+ console.log(`\n📦 ${tool.name}`);
44
+ // Fetch current version
45
+ const version = await fetchVersion(tool);
46
+ console.log(` Version: ${version}`);
47
+ // Store version
48
+ const oldVersion = storage.getVersion(tool.name);
49
+ if (oldVersion !== version) {
50
+ storage.setVersion(tool.name, version);
51
+ console.log(` Updated from ${oldVersion || 'none'} to ${version}`);
52
+ }
53
+ // Mirror asset
54
+ const downloadConfig = downloadsConfig[tool.name];
55
+ const result = await assetMirror.mirror(tool.name, version, downloadConfig.mirror, downloadConfig.filename);
56
+ if (result.success && result.downloadUrl) {
57
+ storage.setMirrorUrl(tool.name, result.downloadUrl);
58
+ console.log(` ✅ Mirrored: ${result.downloadUrl}`);
59
+ }
60
+ else {
61
+ console.log(` ⚠️ Mirror failed: ${result.error}`);
62
+ }
63
+ }
64
+ catch (error) {
65
+ const message = error instanceof Error ? error.message : String(error);
66
+ console.error(` ❌ Error: ${message}`);
67
+ }
68
+ }
69
+ // Publish CLI
70
+ console.log('\n\n📦 Publishing CLI...');
71
+ const state = storage.load();
72
+ const mirrorUrls = storage.getAllMirrorUrls();
73
+ const result = await cliPublisher.publish(state.versions, mirrorUrls);
74
+ if (result.success) {
75
+ console.log(`✅ CLI published: v${result.version}`);
76
+ }
77
+ else {
78
+ console.error(`❌ CLI publish failed: ${result.error}`);
79
+ process.exit(1);
80
+ }
81
+ }
82
+ main().catch((error) => {
83
+ console.error('Fatal error:', error);
84
+ process.exit(1);
85
+ });
package/dist/types.d.ts CHANGED
@@ -8,8 +8,11 @@ export interface ToolConfig {
8
8
  fallbackUrl?: string;
9
9
  customFetcher?: string;
10
10
  }
11
+ export type ScheduleMode = 'interval' | 'daily';
11
12
  export interface Config {
12
13
  checkIntervalHours: number;
14
+ scheduleMode?: ScheduleMode;
15
+ dailyCheckTime?: string;
13
16
  tools: ToolConfig[];
14
17
  }
15
18
  export interface MirrorConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvnt/release-radar",
3
- "version": "1.8.2",
3
+ "version": "1.9.0",
4
4
  "description": "Monitor tool versions and notify via Telegram when updates are detected",
5
5
  "main": "dist/index.js",
6
6
  "bin": {