@lvnt/release-radar 1.6.2 → 1.7.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/cli/bin/release-radar-cli.js +2 -0
- package/cli/package.json +40 -0
- package/cli/src/config.test.ts +48 -0
- package/cli/src/config.ts +33 -0
- package/cli/src/downloader.test.ts +29 -0
- package/cli/src/downloader.ts +56 -0
- package/cli/src/index.ts +170 -0
- package/cli/src/tracker.test.ts +40 -0
- package/cli/src/tracker.ts +47 -0
- package/cli/src/types.ts +34 -0
- package/cli/src/ui.ts +149 -0
- package/cli/src/updater.ts +61 -0
- package/cli/src/versions.ts +12 -0
- package/cli/tsconfig.json +15 -0
- package/cli/versions.json +21 -0
- package/dist/index.js +16 -4
- package/package.json +6 -1
package/cli/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lvnt/release-radar-cli",
|
|
3
|
+
"version": "0.2.2",
|
|
4
|
+
"description": "Interactive CLI for downloading tools through Nexus proxy",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"release-radar-cli": "bin/release-radar-cli.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"dev": "tsx src/index.ts",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"prepublishOnly": "npm run build"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"bin",
|
|
19
|
+
"versions.json"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"release",
|
|
23
|
+
"download",
|
|
24
|
+
"nexus",
|
|
25
|
+
"cli"
|
|
26
|
+
],
|
|
27
|
+
"author": "lvnt",
|
|
28
|
+
"license": "ISC",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"chalk": "^5.3.0",
|
|
31
|
+
"inquirer": "^9.2.12"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/inquirer": "^9.0.7",
|
|
35
|
+
"@types/node": "^20.10.0",
|
|
36
|
+
"tsx": "^4.7.0",
|
|
37
|
+
"typescript": "^5.3.0",
|
|
38
|
+
"vitest": "^1.1.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { ConfigManager } from './config.js';
|
|
3
|
+
import { mkdtempSync, rmSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
|
|
7
|
+
describe('ConfigManager', () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
let configManager: ConfigManager;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tempDir = mkdtempSync(join(tmpdir(), 'cli-test-'));
|
|
13
|
+
configManager = new ConfigManager(tempDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(tempDir, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns false for isConfigured when no config exists', () => {
|
|
21
|
+
expect(configManager.isConfigured()).toBe(false);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('saves and loads config correctly', () => {
|
|
25
|
+
configManager.save({
|
|
26
|
+
nexusUrl: 'http://nexus.local',
|
|
27
|
+
downloadDir: '/downloads',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(configManager.isConfigured()).toBe(true);
|
|
31
|
+
|
|
32
|
+
const loaded = configManager.load();
|
|
33
|
+
expect(loaded.nexusUrl).toBe('http://nexus.local');
|
|
34
|
+
expect(loaded.downloadDir).toBe('/downloads');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('creates config directory if it does not exist', () => {
|
|
38
|
+
const configPath = join(tempDir, 'config.json');
|
|
39
|
+
expect(existsSync(configPath)).toBe(false);
|
|
40
|
+
|
|
41
|
+
configManager.save({
|
|
42
|
+
nexusUrl: 'http://nexus.local',
|
|
43
|
+
downloadDir: '/downloads',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(existsSync(configPath)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
export interface CliConfig {
|
|
6
|
+
nexusUrl: string;
|
|
7
|
+
downloadDir: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ConfigManager {
|
|
11
|
+
private configPath: string;
|
|
12
|
+
|
|
13
|
+
constructor(baseDir?: string) {
|
|
14
|
+
const dir = baseDir ?? join(homedir(), '.release-radar-cli');
|
|
15
|
+
if (!existsSync(dir)) {
|
|
16
|
+
mkdirSync(dir, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
this.configPath = join(dir, 'config.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
isConfigured(): boolean {
|
|
22
|
+
return existsSync(this.configPath);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
load(): CliConfig {
|
|
26
|
+
const content = readFileSync(this.configPath, 'utf-8');
|
|
27
|
+
return JSON.parse(content);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
save(config: CliConfig): void {
|
|
31
|
+
writeFileSync(this.configPath, JSON.stringify(config, null, 2));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildWgetCommand, replaceNexusUrl, buildNpmUpdateCommand } from './downloader.js';
|
|
3
|
+
|
|
4
|
+
describe('downloader', () => {
|
|
5
|
+
describe('replaceNexusUrl', () => {
|
|
6
|
+
it('replaces {{NEXUS_URL}} placeholder', () => {
|
|
7
|
+
const url = '{{NEXUS_URL}}/github.com/ninja/v1.0/ninja.zip';
|
|
8
|
+
const result = replaceNexusUrl(url, 'http://nexus.local');
|
|
9
|
+
expect(result).toBe('http://nexus.local/github.com/ninja/v1.0/ninja.zip');
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('buildWgetCommand', () => {
|
|
14
|
+
it('builds correct wget command', () => {
|
|
15
|
+
const cmd = buildWgetCommand(
|
|
16
|
+
'http://nexus.local/file.zip',
|
|
17
|
+
'/downloads/file.zip'
|
|
18
|
+
);
|
|
19
|
+
expect(cmd).toBe('wget -O "/downloads/file.zip" "http://nexus.local/file.zip"');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('buildNpmUpdateCommand', () => {
|
|
24
|
+
it('builds correct npm update command', () => {
|
|
25
|
+
const cmd = buildNpmUpdateCommand('ralphy-cli');
|
|
26
|
+
expect(cmd).toBe('npm update -g ralphy-cli');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { existsSync, mkdirSync } from 'fs';
|
|
4
|
+
|
|
5
|
+
export function replaceNexusUrl(url: string, nexusUrl: string): string {
|
|
6
|
+
return url.replace('{{NEXUS_URL}}', nexusUrl);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function buildWgetCommand(url: string, outputPath: string): string {
|
|
10
|
+
return `wget -O "${outputPath}" "${url}"`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DownloadResult {
|
|
14
|
+
success: boolean;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function downloadFile(
|
|
19
|
+
url: string,
|
|
20
|
+
downloadDir: string,
|
|
21
|
+
filename: string,
|
|
22
|
+
nexusUrl: string
|
|
23
|
+
): DownloadResult {
|
|
24
|
+
const resolvedUrl = replaceNexusUrl(url, nexusUrl);
|
|
25
|
+
|
|
26
|
+
if (!existsSync(downloadDir)) {
|
|
27
|
+
mkdirSync(downloadDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const outputPath = join(downloadDir, filename);
|
|
31
|
+
const command = buildWgetCommand(resolvedUrl, outputPath);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
execSync(command, { stdio: 'inherit' });
|
|
35
|
+
return { success: true };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
38
|
+
return { success: false, error: message };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function buildNpmUpdateCommand(packageName: string): string {
|
|
43
|
+
return `npm update -g ${packageName}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function updateNpmPackage(packageName: string): DownloadResult {
|
|
47
|
+
const command = buildNpmUpdateCommand(packageName);
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
execSync(command, { stdio: 'inherit' });
|
|
51
|
+
return { success: true };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
54
|
+
return { success: false, error: message };
|
|
55
|
+
}
|
|
56
|
+
}
|
package/cli/src/index.ts
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { ConfigManager } from './config.js';
|
|
3
|
+
import { DownloadTracker } from './tracker.js';
|
|
4
|
+
import { loadVersions } from './versions.js';
|
|
5
|
+
import { checkAndUpdate } from './updater.js';
|
|
6
|
+
import { downloadFile, updateNpmPackage } from './downloader.js';
|
|
7
|
+
import { promptSetup, promptToolSelection, type ToolChoice } from './ui.js';
|
|
8
|
+
import { isNpmTool } from './types.js';
|
|
9
|
+
|
|
10
|
+
async function showStatus(): Promise<void> {
|
|
11
|
+
const tracker = new DownloadTracker();
|
|
12
|
+
const versions = loadVersions();
|
|
13
|
+
const downloaded = tracker.getAll();
|
|
14
|
+
|
|
15
|
+
console.log(chalk.bold('\nTool Status:\n'));
|
|
16
|
+
console.log(chalk.bold(' Tool Latest Downloaded Status Type'));
|
|
17
|
+
console.log(chalk.gray('─'.repeat(70)));
|
|
18
|
+
|
|
19
|
+
for (const tool of versions.tools) {
|
|
20
|
+
const record = downloaded[tool.name];
|
|
21
|
+
const downloadedVersion = record?.version ?? '-';
|
|
22
|
+
|
|
23
|
+
let status: string;
|
|
24
|
+
if (!record) {
|
|
25
|
+
status = chalk.blue('NEW');
|
|
26
|
+
} else if (record.version !== tool.version) {
|
|
27
|
+
status = chalk.yellow('UPDATE');
|
|
28
|
+
} else {
|
|
29
|
+
status = chalk.green('✓');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const typeStr = isNpmTool(tool) ? chalk.magenta('npm') : chalk.cyan('wget');
|
|
33
|
+
console.log(` ${tool.displayName.padEnd(18)} ${tool.version.padEnd(12)} ${downloadedVersion.padEnd(12)} ${status.padEnd(12)} ${typeStr}`);
|
|
34
|
+
}
|
|
35
|
+
console.log('');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runConfig(): Promise<void> {
|
|
39
|
+
const configManager = new ConfigManager();
|
|
40
|
+
const config = await promptSetup();
|
|
41
|
+
configManager.save(config);
|
|
42
|
+
console.log(chalk.green('\nConfiguration saved!'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runInteractive(): Promise<void> {
|
|
46
|
+
const configManager = new ConfigManager();
|
|
47
|
+
const tracker = new DownloadTracker();
|
|
48
|
+
|
|
49
|
+
// Check for updates first (before any prompts) unless --skip-update is passed
|
|
50
|
+
const skipUpdate = process.argv.includes('--skip-update');
|
|
51
|
+
if (!skipUpdate) {
|
|
52
|
+
await checkAndUpdate();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// First run setup
|
|
56
|
+
if (!configManager.isConfigured()) {
|
|
57
|
+
const config = await promptSetup();
|
|
58
|
+
configManager.save(config);
|
|
59
|
+
console.log(chalk.green('\nConfiguration saved!\n'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Load data
|
|
63
|
+
const config = configManager.load();
|
|
64
|
+
const versions = loadVersions();
|
|
65
|
+
const downloaded = tracker.getAll();
|
|
66
|
+
|
|
67
|
+
// Show interactive menu
|
|
68
|
+
const selected = await promptToolSelection(
|
|
69
|
+
versions.tools,
|
|
70
|
+
downloaded,
|
|
71
|
+
versions.generatedAt
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
if (selected.length === 0) {
|
|
75
|
+
console.log(chalk.gray('\nNo tools selected. Exiting.'));
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Download/update selected tools
|
|
80
|
+
console.log('');
|
|
81
|
+
const successes: { name: string; version: string; type: 'download' | 'npm' }[] = [];
|
|
82
|
+
const failures: { name: string; version: string; error: string }[] = [];
|
|
83
|
+
|
|
84
|
+
for (const tool of selected) {
|
|
85
|
+
if (tool.type === 'npm') {
|
|
86
|
+
console.log(chalk.bold(`Updating npm package ${tool.displayName} (${tool.package})...`));
|
|
87
|
+
const result = updateNpmPackage(tool.package);
|
|
88
|
+
|
|
89
|
+
if (result.success) {
|
|
90
|
+
tracker.recordDownload(tool.name, tool.version, `npm:${tool.package}`);
|
|
91
|
+
console.log(chalk.green(` Updated ${tool.package} to ${tool.version} ✓\n`));
|
|
92
|
+
successes.push({ name: tool.displayName, version: tool.version, type: 'npm' });
|
|
93
|
+
} else {
|
|
94
|
+
console.log(chalk.red(` Failed: ${result.error}\n`));
|
|
95
|
+
failures.push({ name: tool.displayName, version: tool.version, error: result.error || 'Unknown error' });
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
console.log(chalk.bold(`Downloading ${tool.displayName} ${tool.version}...`));
|
|
99
|
+
const result = downloadFile(
|
|
100
|
+
tool.downloadUrl,
|
|
101
|
+
config.downloadDir,
|
|
102
|
+
tool.filename,
|
|
103
|
+
config.nexusUrl
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (result.success) {
|
|
107
|
+
tracker.recordDownload(tool.name, tool.version, tool.filename);
|
|
108
|
+
console.log(chalk.green(` Saved to ${config.downloadDir}/${tool.filename} ✓\n`));
|
|
109
|
+
successes.push({ name: tool.displayName, version: tool.version, type: 'download' });
|
|
110
|
+
} else {
|
|
111
|
+
console.log(chalk.red(` Failed: ${result.error}\n`));
|
|
112
|
+
failures.push({ name: tool.displayName, version: tool.version, error: result.error || 'Unknown error' });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Summary report
|
|
118
|
+
console.log(chalk.bold('─'.repeat(50)));
|
|
119
|
+
console.log(chalk.bold('Summary\n'));
|
|
120
|
+
|
|
121
|
+
if (successes.length > 0) {
|
|
122
|
+
console.log(chalk.green(`✓ ${successes.length} succeeded:`));
|
|
123
|
+
for (const s of successes) {
|
|
124
|
+
console.log(chalk.green(` • ${s.name} ${s.version}`));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (failures.length > 0) {
|
|
129
|
+
console.log(chalk.red(`\n✗ ${failures.length} failed:`));
|
|
130
|
+
for (const f of failures) {
|
|
131
|
+
console.log(chalk.red(` • ${f.name} ${f.version}: ${f.error}`));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log('');
|
|
136
|
+
if (failures.length === 0) {
|
|
137
|
+
console.log(chalk.green('All downloads completed successfully!'));
|
|
138
|
+
} else if (successes.length === 0) {
|
|
139
|
+
console.log(chalk.red('All downloads failed.'));
|
|
140
|
+
} else {
|
|
141
|
+
console.log(chalk.yellow(`Completed with ${failures.length} failure(s).`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function main(): Promise<void> {
|
|
146
|
+
const args = process.argv.slice(2).filter(arg => !arg.startsWith('--'));
|
|
147
|
+
const command = args[0];
|
|
148
|
+
|
|
149
|
+
switch (command) {
|
|
150
|
+
case 'status':
|
|
151
|
+
await showStatus();
|
|
152
|
+
break;
|
|
153
|
+
case 'config':
|
|
154
|
+
await runConfig();
|
|
155
|
+
break;
|
|
156
|
+
default:
|
|
157
|
+
// Check if stdin is a TTY for interactive mode
|
|
158
|
+
if (!process.stdin.isTTY) {
|
|
159
|
+
console.error(chalk.red('Error: Interactive mode requires a TTY. Use "status" command for non-interactive use.'));
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
await runInteractive();
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
main().catch((error) => {
|
|
168
|
+
console.error(chalk.red('Error:'), error.message);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { DownloadTracker } from './tracker.js';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
|
|
7
|
+
describe('DownloadTracker', () => {
|
|
8
|
+
let tempDir: string;
|
|
9
|
+
let tracker: DownloadTracker;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tempDir = mkdtempSync(join(tmpdir(), 'tracker-test-'));
|
|
13
|
+
tracker = new DownloadTracker(tempDir);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
rmSync(tempDir, { recursive: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns null for untracked tool', () => {
|
|
21
|
+
expect(tracker.getDownloadedVersion('unknown')).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('tracks downloaded version', () => {
|
|
25
|
+
tracker.recordDownload('Ninja', '1.12.0', 'ninja-1.12.0.zip');
|
|
26
|
+
|
|
27
|
+
const version = tracker.getDownloadedVersion('Ninja');
|
|
28
|
+
expect(version).toBe('1.12.0');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns all downloaded tools', () => {
|
|
32
|
+
tracker.recordDownload('Ninja', '1.12.0', 'ninja.zip');
|
|
33
|
+
tracker.recordDownload('CMake', '3.28.0', 'cmake.tar.gz');
|
|
34
|
+
|
|
35
|
+
const all = tracker.getAll();
|
|
36
|
+
expect(Object.keys(all)).toHaveLength(2);
|
|
37
|
+
expect(all['Ninja'].version).toBe('1.12.0');
|
|
38
|
+
expect(all['CMake'].version).toBe('3.28.0');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
export interface DownloadRecord {
|
|
6
|
+
version: string;
|
|
7
|
+
downloadedAt: string;
|
|
8
|
+
filename: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DownloadedState {
|
|
12
|
+
[toolName: string]: DownloadRecord;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class DownloadTracker {
|
|
16
|
+
private filePath: string;
|
|
17
|
+
|
|
18
|
+
constructor(baseDir?: string) {
|
|
19
|
+
const dir = baseDir ?? join(homedir(), '.release-radar-cli');
|
|
20
|
+
if (!existsSync(dir)) {
|
|
21
|
+
mkdirSync(dir, { recursive: true });
|
|
22
|
+
}
|
|
23
|
+
this.filePath = join(dir, 'downloaded.json');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
getAll(): DownloadedState {
|
|
27
|
+
if (!existsSync(this.filePath)) {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
return JSON.parse(readFileSync(this.filePath, 'utf-8'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
getDownloadedVersion(toolName: string): string | null {
|
|
34
|
+
const all = this.getAll();
|
|
35
|
+
return all[toolName]?.version ?? null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
recordDownload(toolName: string, version: string, filename: string): void {
|
|
39
|
+
const all = this.getAll();
|
|
40
|
+
all[toolName] = {
|
|
41
|
+
version,
|
|
42
|
+
downloadedAt: new Date().toISOString(),
|
|
43
|
+
filename,
|
|
44
|
+
};
|
|
45
|
+
writeFileSync(this.filePath, JSON.stringify(all, null, 2));
|
|
46
|
+
}
|
|
47
|
+
}
|
package/cli/src/types.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface VersionsJsonToolDownload {
|
|
2
|
+
name: string;
|
|
3
|
+
displayName: string;
|
|
4
|
+
version: string;
|
|
5
|
+
publishedAt: string;
|
|
6
|
+
type?: 'download'; // default
|
|
7
|
+
downloadUrl: string;
|
|
8
|
+
filename: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface VersionsJsonToolNpm {
|
|
12
|
+
name: string;
|
|
13
|
+
displayName: string;
|
|
14
|
+
version: string;
|
|
15
|
+
publishedAt: string;
|
|
16
|
+
type: 'npm';
|
|
17
|
+
package: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type VersionsJsonTool = VersionsJsonToolDownload | VersionsJsonToolNpm;
|
|
21
|
+
|
|
22
|
+
export interface VersionsJson {
|
|
23
|
+
generatedAt: string;
|
|
24
|
+
tools: VersionsJsonTool[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Type guards
|
|
28
|
+
export function isNpmTool(tool: VersionsJsonTool): tool is VersionsJsonToolNpm {
|
|
29
|
+
return tool.type === 'npm';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isDownloadTool(tool: VersionsJsonTool): tool is VersionsJsonToolDownload {
|
|
33
|
+
return tool.type !== 'npm';
|
|
34
|
+
}
|
package/cli/src/ui.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import type { VersionsJsonTool } from './types.js';
|
|
4
|
+
import { isNpmTool } from './types.js';
|
|
5
|
+
import type { DownloadedState } from './tracker.js';
|
|
6
|
+
import type { CliConfig } from './config.js';
|
|
7
|
+
|
|
8
|
+
export async function promptSetup(): Promise<CliConfig> {
|
|
9
|
+
console.log(chalk.bold('\nWelcome to release-radar-cli!\n'));
|
|
10
|
+
console.log("Let's configure your settings.\n");
|
|
11
|
+
|
|
12
|
+
const answers = await inquirer.prompt([
|
|
13
|
+
{
|
|
14
|
+
type: 'input',
|
|
15
|
+
name: 'nexusUrl',
|
|
16
|
+
message: 'Enter your Nexus proxy base URL:',
|
|
17
|
+
validate: (input: string) => {
|
|
18
|
+
if (!input.trim()) return 'URL is required';
|
|
19
|
+
if (!input.startsWith('http')) return 'URL must start with http:// or https://';
|
|
20
|
+
return true;
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
type: 'input',
|
|
25
|
+
name: 'downloadDir',
|
|
26
|
+
message: 'Enter download directory:',
|
|
27
|
+
default: '~/downloads/tools',
|
|
28
|
+
validate: (input: string) => input.trim() ? true : 'Directory is required',
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
nexusUrl: answers.nexusUrl.replace(/\/$/, ''), // Remove trailing slash
|
|
34
|
+
downloadDir: answers.downloadDir.replace('~', process.env.HOME || ''),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ToolChoiceBase {
|
|
39
|
+
name: string;
|
|
40
|
+
displayName: string;
|
|
41
|
+
version: string;
|
|
42
|
+
status: 'new' | 'update' | 'current';
|
|
43
|
+
downloadedVersion: string | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ToolChoiceDownload extends ToolChoiceBase {
|
|
47
|
+
type: 'download';
|
|
48
|
+
downloadUrl: string;
|
|
49
|
+
filename: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface ToolChoiceNpm extends ToolChoiceBase {
|
|
53
|
+
type: 'npm';
|
|
54
|
+
package: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ToolChoice = ToolChoiceDownload | ToolChoiceNpm;
|
|
58
|
+
|
|
59
|
+
function getStatus(
|
|
60
|
+
tool: VersionsJsonTool,
|
|
61
|
+
downloaded: DownloadedState
|
|
62
|
+
): { status: 'new' | 'update' | 'current'; downloadedVersion: string | null } {
|
|
63
|
+
const record = downloaded[tool.name];
|
|
64
|
+
if (!record) {
|
|
65
|
+
return { status: 'new', downloadedVersion: null };
|
|
66
|
+
}
|
|
67
|
+
if (record.version !== tool.version) {
|
|
68
|
+
return { status: 'update', downloadedVersion: record.version };
|
|
69
|
+
}
|
|
70
|
+
return { status: 'current', downloadedVersion: record.version };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function createToolChoice(tool: VersionsJsonTool, downloaded: DownloadedState): ToolChoice {
|
|
74
|
+
const { status, downloadedVersion } = getStatus(tool, downloaded);
|
|
75
|
+
const base = {
|
|
76
|
+
name: tool.name,
|
|
77
|
+
displayName: tool.displayName,
|
|
78
|
+
version: tool.version,
|
|
79
|
+
status,
|
|
80
|
+
downloadedVersion,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
if (isNpmTool(tool)) {
|
|
84
|
+
return {
|
|
85
|
+
...base,
|
|
86
|
+
type: 'npm',
|
|
87
|
+
package: tool.package,
|
|
88
|
+
};
|
|
89
|
+
} else {
|
|
90
|
+
return {
|
|
91
|
+
...base,
|
|
92
|
+
type: 'download',
|
|
93
|
+
downloadUrl: tool.downloadUrl,
|
|
94
|
+
filename: tool.filename,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function promptToolSelection(
|
|
100
|
+
tools: VersionsJsonTool[],
|
|
101
|
+
downloaded: DownloadedState,
|
|
102
|
+
generatedAt: string
|
|
103
|
+
): Promise<ToolChoice[]> {
|
|
104
|
+
const choices: ToolChoice[] = tools.map((tool) => createToolChoice(tool, downloaded));
|
|
105
|
+
|
|
106
|
+
console.log(chalk.bold(`\nrelease-radar-cli`));
|
|
107
|
+
console.log(chalk.gray(`Last updated: ${new Date(generatedAt).toLocaleString()}\n`));
|
|
108
|
+
|
|
109
|
+
// Display table
|
|
110
|
+
console.log(chalk.bold(' Tool Latest Downloaded Status Type'));
|
|
111
|
+
console.log(chalk.gray('─'.repeat(70)));
|
|
112
|
+
|
|
113
|
+
choices.forEach((choice) => {
|
|
114
|
+
const downloadedStr = choice.downloadedVersion ?? '-';
|
|
115
|
+
let statusStr: string;
|
|
116
|
+
switch (choice.status) {
|
|
117
|
+
case 'new':
|
|
118
|
+
statusStr = chalk.blue('NEW');
|
|
119
|
+
break;
|
|
120
|
+
case 'update':
|
|
121
|
+
statusStr = chalk.yellow('UPDATE');
|
|
122
|
+
break;
|
|
123
|
+
case 'current':
|
|
124
|
+
statusStr = chalk.green('✓');
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
const typeStr = choice.type === 'npm' ? chalk.magenta('npm') : chalk.cyan('wget');
|
|
128
|
+
console.log(
|
|
129
|
+
` ${choice.displayName.padEnd(18)} ${choice.version.padEnd(12)} ${downloadedStr.padEnd(12)} ${statusStr.padEnd(12)} ${typeStr}`
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
console.log('');
|
|
134
|
+
|
|
135
|
+
const { selected } = await inquirer.prompt([
|
|
136
|
+
{
|
|
137
|
+
type: 'checkbox',
|
|
138
|
+
name: 'selected',
|
|
139
|
+
message: 'Select tools to download:',
|
|
140
|
+
choices: choices.map((choice) => ({
|
|
141
|
+
name: `${choice.displayName} ${choice.version}`,
|
|
142
|
+
value: choice,
|
|
143
|
+
checked: choice.status !== 'current',
|
|
144
|
+
})),
|
|
145
|
+
},
|
|
146
|
+
]);
|
|
147
|
+
|
|
148
|
+
return selected;
|
|
149
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { execSync, spawn } from 'child_process';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname, join } from 'path';
|
|
5
|
+
|
|
6
|
+
function getCurrentVersion(): string {
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const pkgPath = join(__dirname, '..', 'package.json');
|
|
9
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
10
|
+
return pkg.version;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getLatestVersion(): string | null {
|
|
14
|
+
try {
|
|
15
|
+
const result = execSync('npm view @lvnt/release-radar-cli version', {
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
18
|
+
timeout: 10000, // 10 second timeout
|
|
19
|
+
});
|
|
20
|
+
return result.trim();
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function checkAndUpdate(): Promise<boolean> {
|
|
27
|
+
const current = getCurrentVersion();
|
|
28
|
+
const latest = getLatestVersion();
|
|
29
|
+
|
|
30
|
+
if (!latest) {
|
|
31
|
+
// Can't reach npm registry, continue with current
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (current === latest) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
console.log(`Updating from ${current} to ${latest}...`);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
// Use pipe instead of inherit to avoid messing with terminal state
|
|
43
|
+
const result = execSync('npm update -g @lvnt/release-radar-cli', {
|
|
44
|
+
encoding: 'utf-8',
|
|
45
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
46
|
+
});
|
|
47
|
+
if (result) console.log(result);
|
|
48
|
+
console.log('Update complete. Restarting...\n');
|
|
49
|
+
|
|
50
|
+
// Restart self with a fresh terminal
|
|
51
|
+
const child = spawn(process.argv[0], process.argv.slice(1), {
|
|
52
|
+
detached: true,
|
|
53
|
+
stdio: 'inherit',
|
|
54
|
+
});
|
|
55
|
+
child.unref();
|
|
56
|
+
process.exit(0);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Update failed, continuing with current version');
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
import type { VersionsJson } from './types.js';
|
|
5
|
+
|
|
6
|
+
export function loadVersions(): VersionsJson {
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
// In dist/, versions.json is at package root
|
|
9
|
+
const versionsPath = join(__dirname, '..', 'versions.json');
|
|
10
|
+
const content = readFileSync(versionsPath, 'utf-8');
|
|
11
|
+
return JSON.parse(content);
|
|
12
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "./dist",
|
|
7
|
+
"rootDir": "./src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*"],
|
|
14
|
+
"exclude": ["node_modules", "dist"]
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generatedAt": "2026-01-24T10:00:00Z",
|
|
3
|
+
"tools": [
|
|
4
|
+
{
|
|
5
|
+
"name": "Ninja",
|
|
6
|
+
"displayName": "Ninja",
|
|
7
|
+
"version": "1.12.0",
|
|
8
|
+
"publishedAt": "2026-01-20T00:00:00Z",
|
|
9
|
+
"downloadUrl": "{{NEXUS_URL}}/github.com/ninja-build/ninja/releases/download/v1.12.0/ninja-linux.zip",
|
|
10
|
+
"filename": "ninja-1.12.0-linux.zip"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"name": "CMake",
|
|
14
|
+
"displayName": "CMake",
|
|
15
|
+
"version": "3.28.1",
|
|
16
|
+
"publishedAt": "2026-01-18T00:00:00Z",
|
|
17
|
+
"downloadUrl": "{{NEXUS_URL}}/github.com/Kitware/CMake/releases/download/v3.28.1/cmake-3.28.1-linux-x86_64.tar.gz",
|
|
18
|
+
"filename": "cmake-3.28.1-linux-x86_64.tar.gz"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { config } from 'dotenv';
|
|
|
3
3
|
config();
|
|
4
4
|
import TelegramBot from 'node-telegram-bot-api';
|
|
5
5
|
import cron from 'node-cron';
|
|
6
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, cpSync } from 'fs';
|
|
7
7
|
import { fileURLToPath } from 'url';
|
|
8
8
|
import { dirname, join } from 'path';
|
|
9
9
|
import { Storage } from './storage.js';
|
|
@@ -49,18 +49,30 @@ try {
|
|
|
49
49
|
catch {
|
|
50
50
|
console.log('No downloads.json found, CLI generation disabled');
|
|
51
51
|
}
|
|
52
|
-
// Data directory - use
|
|
53
|
-
const
|
|
52
|
+
// Data directory - use ~/.release-radar for user-writable storage
|
|
53
|
+
const HOME_DIR = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
54
|
+
const DATA_DIR = process.env.RELEASE_RADAR_DATA_DIR || join(HOME_DIR, '.release-radar');
|
|
54
55
|
if (!existsSync(DATA_DIR)) {
|
|
55
56
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
56
57
|
}
|
|
57
58
|
console.log(`Data directory: ${DATA_DIR}`);
|
|
59
|
+
// Copy cli/ to user directory for publishing (needs to be writable)
|
|
60
|
+
const PKG_CLI_DIR = join(PKG_ROOT, 'cli');
|
|
61
|
+
const USER_CLI_DIR = join(DATA_DIR, 'cli');
|
|
62
|
+
if (existsSync(PKG_CLI_DIR) && !existsSync(USER_CLI_DIR)) {
|
|
63
|
+
console.log(`Copying CLI source to ${USER_CLI_DIR}...`);
|
|
64
|
+
cpSync(PKG_CLI_DIR, USER_CLI_DIR, { recursive: true });
|
|
65
|
+
console.log('CLI source copied successfully');
|
|
66
|
+
}
|
|
67
|
+
else if (existsSync(USER_CLI_DIR)) {
|
|
68
|
+
console.log(`CLI source directory: ${USER_CLI_DIR}`);
|
|
69
|
+
}
|
|
58
70
|
// Initialize components
|
|
59
71
|
const bot = new TelegramBot(BOT_TOKEN, { polling: true });
|
|
60
72
|
const storage = new Storage(join(DATA_DIR, 'versions.json'));
|
|
61
73
|
const notifier = new Notifier(bot, validatedChatId);
|
|
62
74
|
const checker = new Checker(configData.tools, storage, notifier);
|
|
63
|
-
const cliPublisher = new CliPublisher(downloadsConfig,
|
|
75
|
+
const cliPublisher = new CliPublisher(downloadsConfig, USER_CLI_DIR);
|
|
64
76
|
// Track scheduled task for rescheduling
|
|
65
77
|
let scheduledTask = null;
|
|
66
78
|
let lastCheckTime = null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lvnt/release-radar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Monitor tool versions and notify via Telegram when updates are detected",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -40,6 +40,11 @@
|
|
|
40
40
|
"dist",
|
|
41
41
|
"bin",
|
|
42
42
|
"config",
|
|
43
|
+
"cli/src",
|
|
44
|
+
"cli/bin",
|
|
45
|
+
"cli/package.json",
|
|
46
|
+
"cli/tsconfig.json",
|
|
47
|
+
"cli/versions.json",
|
|
43
48
|
".env.example"
|
|
44
49
|
],
|
|
45
50
|
"dependencies": {
|