@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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../dist/index.js';
@@ -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
+ }
@@ -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
+ }
@@ -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 package root for now, could be made configurable
53
- const DATA_DIR = join(PKG_ROOT, 'data');
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, join(PKG_ROOT, 'cli'));
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.6.2",
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": {