mirror of
https://github.com/eyaltoledano/claude-task-master.git
synced 2026-01-30 06:12:05 +00:00
feat: improve tm auto-update display
This commit is contained in:
@@ -26,6 +26,7 @@
|
||||
"@tm/core": "*",
|
||||
"boxen": "^8.0.1",
|
||||
"chalk": "5.6.2",
|
||||
"cli-progress": "^3.12.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^12.1.0",
|
||||
"inquirer": "^12.5.0",
|
||||
@@ -33,6 +34,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/inquirer": "^9.0.3",
|
||||
"@types/node": "^22.10.5",
|
||||
"@vitest/coverage-v8": "^4.0.10",
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Auto-update utilities for task-master-ai CLI
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import https from 'https';
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
import ora from 'ora';
|
||||
import process from 'process';
|
||||
|
||||
export interface UpdateInfo {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
needsUpdate: boolean;
|
||||
highlights?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current version from build-time injected environment variable
|
||||
*/
|
||||
function getCurrentVersion(): string {
|
||||
// Version is injected at build time via TM_PUBLIC_VERSION
|
||||
const version = process.env.TM_PUBLIC_VERSION;
|
||||
if (version && version !== 'unknown') {
|
||||
return version;
|
||||
}
|
||||
|
||||
// Fallback for development or if injection failed
|
||||
console.warn('Could not read version from TM_PUBLIC_VERSION, using fallback');
|
||||
return '0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semantic versions with proper pre-release handling
|
||||
* @param v1 - First version
|
||||
* @param v2 - Second version
|
||||
* @returns -1 if v1 < v2, 0 if v1 = v2, 1 if v1 > v2
|
||||
*/
|
||||
export function compareVersions(v1: string, v2: string): number {
|
||||
const toParts = (v: string) => {
|
||||
const [core, pre = ''] = v.split('-', 2);
|
||||
const nums = core.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
||||
return { nums, pre };
|
||||
};
|
||||
|
||||
const a = toParts(v1);
|
||||
const b = toParts(v2);
|
||||
const len = Math.max(a.nums.length, b.nums.length);
|
||||
|
||||
// Compare numeric parts
|
||||
for (let i = 0; i < len; i++) {
|
||||
const d = (a.nums[i] || 0) - (b.nums[i] || 0);
|
||||
if (d !== 0) return d < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
// Handle pre-release comparison
|
||||
if (a.pre && !b.pre) return -1; // prerelease < release
|
||||
if (!a.pre && b.pre) return 1; // release > prerelease
|
||||
if (a.pre === b.pre) return 0; // same or both empty
|
||||
return a.pre < b.pre ? -1 : 1; // basic prerelease tie-break
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CHANGELOG.md from GitHub and extract highlights for a specific version
|
||||
*/
|
||||
async function fetchChangelogHighlights(version: string): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'raw.githubusercontent.com',
|
||||
path: '/eyaltoledano/claude-task-master/main/CHANGELOG.md',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': `task-master-ai/${version}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const highlights = parseChangelogHighlights(data, version);
|
||||
resolve(highlights);
|
||||
} catch (error) {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve([]);
|
||||
});
|
||||
|
||||
req.setTimeout(3000, () => {
|
||||
req.destroy();
|
||||
resolve([]);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse changelog markdown to extract Minor Changes for a specific version
|
||||
* @internal - Exported for testing purposes only
|
||||
*/
|
||||
export function parseChangelogHighlights(
|
||||
changelog: string,
|
||||
version: string
|
||||
): string[] {
|
||||
try {
|
||||
// Validate version format (basic semver pattern) to prevent ReDoS
|
||||
if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/.test(version)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find the version section
|
||||
const versionRegex = new RegExp(
|
||||
`## ${version.replace(/\./g, '\\.')}\\s*\\n`,
|
||||
'i'
|
||||
);
|
||||
const versionMatch = changelog.match(versionRegex);
|
||||
|
||||
if (!versionMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract content from this version to the next version heading
|
||||
const startIdx = versionMatch.index! + versionMatch[0].length;
|
||||
const nextVersionIdx = changelog.indexOf('\n## ', startIdx);
|
||||
const versionContent =
|
||||
nextVersionIdx > 0
|
||||
? changelog.slice(startIdx, nextVersionIdx)
|
||||
: changelog.slice(startIdx);
|
||||
|
||||
// Find Minor Changes section
|
||||
const minorChangesMatch = versionContent.match(
|
||||
/### Minor Changes\s*\n([\s\S]*?)(?=\n###|\n##|$)/i
|
||||
);
|
||||
|
||||
if (!minorChangesMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const minorChangesContent = minorChangesMatch[1];
|
||||
const highlights: string[] = [];
|
||||
|
||||
// Extract all bullet points (lines starting with -)
|
||||
// Format: - [#PR](...) Thanks [@author]! - Description
|
||||
const bulletRegex = /^-\s+\[#\d+\][^\n]*?!\s+-\s+(.+?)$/gm;
|
||||
let match;
|
||||
|
||||
while ((match = bulletRegex.exec(minorChangesContent)) !== null) {
|
||||
const desc = match[1].trim();
|
||||
highlights.push(desc);
|
||||
}
|
||||
|
||||
return highlights;
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for newer version of task-master-ai
|
||||
*/
|
||||
export async function checkForUpdate(
|
||||
currentVersionOverride?: string
|
||||
): Promise<UpdateInfo> {
|
||||
const currentVersion = currentVersionOverride || getCurrentVersion();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'registry.npmjs.org',
|
||||
path: '/task-master-ai',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.npm.install-v1+json',
|
||||
'User-Agent': `task-master-ai/${currentVersion}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', async () => {
|
||||
try {
|
||||
if (res.statusCode !== 200)
|
||||
throw new Error(`npm registry status ${res.statusCode}`);
|
||||
const npmData = JSON.parse(data);
|
||||
const latestVersion = npmData['dist-tags']?.latest || currentVersion;
|
||||
|
||||
const needsUpdate =
|
||||
compareVersions(currentVersion, latestVersion) < 0;
|
||||
|
||||
// Fetch highlights if update is needed
|
||||
let highlights: string[] | undefined;
|
||||
if (needsUpdate) {
|
||||
highlights = await fetchChangelogHighlights(latestVersion);
|
||||
}
|
||||
|
||||
resolve({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
needsUpdate,
|
||||
highlights
|
||||
});
|
||||
} catch (error) {
|
||||
resolve({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
needsUpdate: false
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
needsUpdate: false
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(3000, () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
needsUpdate: false
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Display upgrade notification message
|
||||
*/
|
||||
export function displayUpgradeNotification(
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
highlights?: string[]
|
||||
) {
|
||||
let content = `${chalk.blue.bold('Update Available!')} ${chalk.dim(currentVersion)} → ${chalk.green(latestVersion)}`;
|
||||
|
||||
if (highlights && highlights.length > 0) {
|
||||
content += '\n\n' + chalk.bold("What's New:");
|
||||
for (const highlight of highlights) {
|
||||
content += '\n' + chalk.cyan('• ') + highlight;
|
||||
}
|
||||
content += '\n\n' + 'Auto-updating to the latest version...';
|
||||
} else {
|
||||
content +=
|
||||
'\n\n' +
|
||||
'Auto-updating to the latest version with new features and bug fixes...';
|
||||
}
|
||||
|
||||
const message = boxen(content, {
|
||||
padding: 1,
|
||||
margin: { top: 1, bottom: 1 },
|
||||
borderColor: 'yellow',
|
||||
borderStyle: 'round'
|
||||
});
|
||||
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically update task-master-ai to the latest version
|
||||
*/
|
||||
export async function performAutoUpdate(
|
||||
latestVersion: string
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
process.env.TASKMASTER_SKIP_AUTO_UPDATE === '1' ||
|
||||
process.env.CI ||
|
||||
process.env.NODE_ENV === 'test'
|
||||
) {
|
||||
const reason =
|
||||
process.env.TASKMASTER_SKIP_AUTO_UPDATE === '1'
|
||||
? 'TASKMASTER_SKIP_AUTO_UPDATE=1'
|
||||
: process.env.CI
|
||||
? 'CI environment'
|
||||
: 'NODE_ENV=test';
|
||||
console.log(chalk.dim(`Skipping auto-update (${reason})`));
|
||||
return false;
|
||||
}
|
||||
const spinner = ora({
|
||||
text: chalk.blue(
|
||||
`Updating task-master-ai to version ${chalk.green(latestVersion)}`
|
||||
),
|
||||
spinner: 'dots',
|
||||
color: 'blue'
|
||||
}).start();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const updateProcess = spawn(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'-g',
|
||||
`task-master-ai@${latestVersion}`,
|
||||
'--no-fund',
|
||||
'--no-audit',
|
||||
'--loglevel=warn'
|
||||
],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
|
||||
let errorOutput = '';
|
||||
|
||||
updateProcess.stdout.on('data', () => {
|
||||
// Update spinner text with progress
|
||||
spinner.text = chalk.blue(
|
||||
`Installing task-master-ai@${latestVersion}...`
|
||||
);
|
||||
});
|
||||
|
||||
updateProcess.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
updateProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
spinner.succeed(
|
||||
chalk.green(
|
||||
`Successfully updated to version ${chalk.bold(latestVersion)}`
|
||||
)
|
||||
);
|
||||
resolve(true);
|
||||
} else {
|
||||
spinner.fail(chalk.red('Auto-update failed'));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`Please run manually: npm install -g task-master-ai@${latestVersion}`
|
||||
)
|
||||
);
|
||||
if (errorOutput) {
|
||||
console.log(chalk.dim(`Error: ${errorOutput.trim()}`));
|
||||
}
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
updateProcess.on('error', (error) => {
|
||||
spinner.fail(chalk.red('Auto-update failed'));
|
||||
console.log(chalk.red('Error:'), error.message);
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`Please run manually: npm install -g task-master-ai@${latestVersion}`
|
||||
)
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the CLI with the newly installed version
|
||||
* @param argv - Original command-line arguments (process.argv)
|
||||
*/
|
||||
export function restartWithNewVersion(argv: string[]): void {
|
||||
const args = argv.slice(2); // Remove 'node' and script path
|
||||
|
||||
console.log(chalk.dim('Restarting with updated version...\n'));
|
||||
|
||||
// Spawn the updated task-master command
|
||||
const child = spawn('task-master', args, {
|
||||
stdio: 'inherit', // Inherit stdin/stdout/stderr so it looks seamless
|
||||
detached: false,
|
||||
shell: process.platform === 'win32' // Windows compatibility
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(
|
||||
chalk.red('Failed to restart with new version:'),
|
||||
error.message
|
||||
);
|
||||
console.log(chalk.yellow('Please run your command again manually.'));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
117
apps/cli/src/utils/auto-update/changelog.ts
Normal file
117
apps/cli/src/utils/auto-update/changelog.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @fileoverview Changelog fetching and parsing utilities
|
||||
*/
|
||||
|
||||
import https from 'https';
|
||||
|
||||
/**
|
||||
* Fetch CHANGELOG.md from GitHub and extract highlights for a specific version
|
||||
*/
|
||||
export async function fetchChangelogHighlights(
|
||||
version: string
|
||||
): Promise<string[]> {
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'raw.githubusercontent.com',
|
||||
path: '/eyaltoledano/claude-task-master/main/CHANGELOG.md',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': `task-master-ai/${version}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const highlights = parseChangelogHighlights(data, version);
|
||||
resolve(highlights);
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve([]);
|
||||
});
|
||||
|
||||
req.setTimeout(3000, () => {
|
||||
req.destroy();
|
||||
resolve([]);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse changelog markdown to extract Minor Changes for a specific version
|
||||
* @internal - Exported for testing purposes only
|
||||
*/
|
||||
export function parseChangelogHighlights(
|
||||
changelog: string,
|
||||
version: string
|
||||
): string[] {
|
||||
try {
|
||||
// Validate version format (basic semver pattern) to prevent ReDoS
|
||||
if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/.test(version)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find the version section
|
||||
const versionRegex = new RegExp(
|
||||
`## ${version.replace(/\./g, '\\.')}\\s*\\n`,
|
||||
'i'
|
||||
);
|
||||
const versionMatch = changelog.match(versionRegex);
|
||||
|
||||
if (!versionMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Extract content from this version to the next version heading
|
||||
const startIdx = versionMatch.index! + versionMatch[0].length;
|
||||
const nextVersionIdx = changelog.indexOf('\n## ', startIdx);
|
||||
const versionContent =
|
||||
nextVersionIdx > 0
|
||||
? changelog.slice(startIdx, nextVersionIdx)
|
||||
: changelog.slice(startIdx);
|
||||
|
||||
// Find Minor Changes section
|
||||
const minorChangesMatch = versionContent.match(
|
||||
/### Minor Changes\s*\n([\s\S]*?)(?=\n###|\n##|$)/i
|
||||
);
|
||||
|
||||
if (!minorChangesMatch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const minorChangesContent = minorChangesMatch[1];
|
||||
const highlights: string[] = [];
|
||||
|
||||
// Extract all bullet points (lines starting with -)
|
||||
// Format: - [#PR](...) Thanks [@author]! - Description
|
||||
const bulletRegex = /^-\s+\[#\d+\][^\n]*?!\s+-\s+(.+?)$/gm;
|
||||
let match;
|
||||
|
||||
while ((match = bulletRegex.exec(minorChangesContent)) !== null) {
|
||||
const desc = match[1].trim();
|
||||
highlights.push(desc);
|
||||
}
|
||||
|
||||
return highlights;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
88
apps/cli/src/utils/auto-update/check-update.ts
Normal file
88
apps/cli/src/utils/auto-update/check-update.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* @fileoverview Check npm registry for package updates
|
||||
*/
|
||||
|
||||
import https from 'https';
|
||||
|
||||
import type { UpdateInfo } from './types.js';
|
||||
import { compareVersions, getCurrentVersion } from './version.js';
|
||||
import { fetchChangelogHighlights } from './changelog.js';
|
||||
|
||||
/**
|
||||
* Check for newer version of task-master-ai
|
||||
*/
|
||||
export async function checkForUpdate(
|
||||
currentVersionOverride?: string
|
||||
): Promise<UpdateInfo> {
|
||||
const currentVersion = currentVersionOverride || getCurrentVersion();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'registry.npmjs.org',
|
||||
path: '/task-master-ai',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/vnd.npm.install-v1+json',
|
||||
'User-Agent': `task-master-ai/${currentVersion}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', async () => {
|
||||
try {
|
||||
if (res.statusCode !== 200)
|
||||
throw new Error(`npm registry status ${res.statusCode}`);
|
||||
const npmData = JSON.parse(data);
|
||||
const latestVersion = npmData['dist-tags']?.latest || currentVersion;
|
||||
|
||||
const needsUpdate =
|
||||
compareVersions(currentVersion, latestVersion) < 0;
|
||||
|
||||
// Fetch highlights if update is needed
|
||||
let highlights: string[] | undefined;
|
||||
if (needsUpdate) {
|
||||
highlights = await fetchChangelogHighlights(latestVersion);
|
||||
}
|
||||
|
||||
resolve({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
needsUpdate,
|
||||
highlights
|
||||
});
|
||||
} catch {
|
||||
resolve({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
needsUpdate: false
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
needsUpdate: false
|
||||
});
|
||||
});
|
||||
|
||||
req.setTimeout(3000, () => {
|
||||
req.destroy();
|
||||
resolve({
|
||||
currentVersion,
|
||||
latestVersion: currentVersion,
|
||||
needsUpdate: false
|
||||
});
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
38
apps/cli/src/utils/auto-update/display.ts
Normal file
38
apps/cli/src/utils/auto-update/display.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @fileoverview Update notification display utilities
|
||||
*/
|
||||
|
||||
import boxen from 'boxen';
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* Display upgrade notification message
|
||||
*/
|
||||
export function displayUpgradeNotification(
|
||||
currentVersion: string,
|
||||
latestVersion: string,
|
||||
highlights?: string[]
|
||||
) {
|
||||
let content = `${chalk.blue.bold('Update Available!')} ${chalk.dim(currentVersion)} → ${chalk.green(latestVersion)}`;
|
||||
|
||||
if (highlights && highlights.length > 0) {
|
||||
content += '\n\n' + chalk.bold("What's New:");
|
||||
for (const highlight of highlights) {
|
||||
content += '\n' + chalk.cyan('• ') + highlight;
|
||||
}
|
||||
content += '\n\n' + 'Auto-updating to the latest version...';
|
||||
} else {
|
||||
content +=
|
||||
'\n\n' +
|
||||
'Auto-updating to the latest version with new features and bug fixes...';
|
||||
}
|
||||
|
||||
const message = boxen(content, {
|
||||
padding: 1,
|
||||
margin: { top: 1, bottom: 1 },
|
||||
borderColor: 'yellow',
|
||||
borderStyle: 'round'
|
||||
});
|
||||
|
||||
console.log(message);
|
||||
}
|
||||
201
apps/cli/src/utils/auto-update/download.ts
Normal file
201
apps/cli/src/utils/auto-update/download.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* @fileoverview Tarball download with progress bar
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import https from 'https';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import cliProgress from 'cli-progress';
|
||||
|
||||
import type { TarballInfo } from './types.js';
|
||||
|
||||
/**
|
||||
* Format bytes to human-readable string
|
||||
*/
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tarball info (URL and size) from npm registry
|
||||
*/
|
||||
export async function fetchTarballInfo(
|
||||
version: string
|
||||
): Promise<TarballInfo | null> {
|
||||
return new Promise((resolve) => {
|
||||
const options = {
|
||||
hostname: 'registry.npmjs.org',
|
||||
path: `/task-master-ai/${version}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `task-master-ai/${version}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
try {
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageData = JSON.parse(data);
|
||||
const tarballUrl = packageData.dist?.tarball;
|
||||
const unpackedSize = packageData.dist?.unpackedSize;
|
||||
|
||||
if (!tarballUrl) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
url: tarballUrl,
|
||||
size: unpackedSize || 0
|
||||
});
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.setTimeout(10000, () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download tarball with progress bar
|
||||
*/
|
||||
export async function downloadTarballWithProgress(
|
||||
tarballUrl: string,
|
||||
destPath: string,
|
||||
version: string
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const url = new URL(tarballUrl);
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': `task-master-ai/${version}`
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
// Handle redirects
|
||||
if (res.statusCode === 301 || res.statusCode === 302) {
|
||||
const redirectUrl = res.headers.location;
|
||||
if (redirectUrl) {
|
||||
downloadTarballWithProgress(redirectUrl, destPath, version)
|
||||
.then(resolve)
|
||||
.catch(() => resolve(false));
|
||||
return;
|
||||
}
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const totalSize = Number.parseInt(
|
||||
res.headers['content-length'] || '0',
|
||||
10
|
||||
);
|
||||
let downloadedSize = 0;
|
||||
|
||||
// Create progress bar
|
||||
const progressBar = new cliProgress.SingleBar(
|
||||
{
|
||||
format: `${chalk.blue('Downloading')} ${chalk.cyan('{bar}')} {percentage}% | {downloaded}/{total}`,
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
hideCursor: true,
|
||||
clearOnComplete: true
|
||||
},
|
||||
cliProgress.Presets.shades_classic
|
||||
);
|
||||
|
||||
if (totalSize > 0) {
|
||||
progressBar.start(totalSize, 0, {
|
||||
downloaded: formatBytes(0),
|
||||
total: formatBytes(totalSize)
|
||||
});
|
||||
} else {
|
||||
// If no content-length, show indeterminate progress
|
||||
console.log(chalk.blue(`Downloading task-master-ai@${version}...`));
|
||||
}
|
||||
|
||||
const fileStream = fs.createWriteStream(destPath);
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
downloadedSize += chunk.length;
|
||||
if (totalSize > 0) {
|
||||
progressBar.update(downloadedSize, {
|
||||
downloaded: formatBytes(downloadedSize),
|
||||
total: formatBytes(totalSize)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
res.pipe(fileStream);
|
||||
|
||||
fileStream.on('finish', () => {
|
||||
if (totalSize > 0) {
|
||||
progressBar.stop();
|
||||
}
|
||||
console.log(
|
||||
chalk.green('✓') +
|
||||
chalk.dim(` Downloaded ${formatBytes(downloadedSize)}`)
|
||||
);
|
||||
fileStream.close();
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
fileStream.on('error', (err) => {
|
||||
if (totalSize > 0) {
|
||||
progressBar.stop();
|
||||
}
|
||||
console.error(chalk.red('Download error:'), err.message);
|
||||
fs.unlink(destPath, () => {}); // Cleanup partial file
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error(chalk.red('Request error:'), err.message);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.setTimeout(120000, () => {
|
||||
req.destroy();
|
||||
console.error(chalk.red('Download timeout'));
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
30
apps/cli/src/utils/auto-update/index.ts
Normal file
30
apps/cli/src/utils/auto-update/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* @fileoverview Auto-update utilities for task-master-ai CLI
|
||||
*
|
||||
* This module provides functionality for:
|
||||
* - Checking for updates from npm registry
|
||||
* - Downloading and installing updates with progress bar
|
||||
* - Displaying update notifications
|
||||
* - Restarting CLI after updates
|
||||
*/
|
||||
|
||||
// Re-export types
|
||||
export type { UpdateInfo, TarballInfo } from './types.js';
|
||||
|
||||
// Re-export version utilities
|
||||
export { compareVersions } from './version.js';
|
||||
|
||||
// Re-export changelog utilities (parseChangelogHighlights exported for testing)
|
||||
export { parseChangelogHighlights } from './changelog.js';
|
||||
|
||||
// Re-export update checking
|
||||
export { checkForUpdate } from './check-update.js';
|
||||
|
||||
// Re-export display utilities
|
||||
export { displayUpgradeNotification } from './display.js';
|
||||
|
||||
// Re-export installation
|
||||
export { performAutoUpdate } from './install.js';
|
||||
|
||||
// Re-export restart functionality
|
||||
export { restartWithNewVersion } from './restart.js';
|
||||
376
apps/cli/src/utils/auto-update/install.ts
Normal file
376
apps/cli/src/utils/auto-update/install.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* @fileoverview Package installation operations
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import process from 'process';
|
||||
|
||||
import chalk from 'chalk';
|
||||
import cliProgress from 'cli-progress';
|
||||
import ora from 'ora';
|
||||
|
||||
import { downloadTarballWithProgress, fetchTarballInfo } from './download.js';
|
||||
|
||||
/** Installation phases with weights (how much of the progress bar each phase takes) */
|
||||
const INSTALL_PHASES = [
|
||||
{ name: 'Extracting package', weight: 15 },
|
||||
{ name: 'Resolving dependencies', weight: 25 },
|
||||
{ name: 'Building dependency tree', weight: 20 },
|
||||
{ name: 'Linking package', weight: 25 },
|
||||
{ name: 'Finalizing installation', weight: 15 }
|
||||
];
|
||||
|
||||
/** Total weight for percentage calculation */
|
||||
const TOTAL_WEIGHT = INSTALL_PHASES.reduce((sum, p) => sum + p.weight, 0);
|
||||
|
||||
/**
|
||||
* Parse npm output to determine current installation phase index
|
||||
*/
|
||||
function parseNpmPhaseIndex(output: string): number {
|
||||
const lowerOutput = output.toLowerCase();
|
||||
|
||||
if (lowerOutput.includes('extract') || lowerOutput.includes('unpack')) {
|
||||
return 0;
|
||||
}
|
||||
if (lowerOutput.includes('resolv') || lowerOutput.includes('fetch')) {
|
||||
return 1;
|
||||
}
|
||||
if (lowerOutput.includes('build') || lowerOutput.includes('tree')) {
|
||||
return 2;
|
||||
}
|
||||
if (lowerOutput.includes('link') || lowerOutput.includes('bin')) {
|
||||
return 3;
|
||||
}
|
||||
if (lowerOutput.includes('added') || lowerOutput.includes('done')) {
|
||||
return 4;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate progress percentage based on phase and sub-progress within phase
|
||||
*/
|
||||
function calculateProgress(phaseIndex: number, phaseProgress: number): number {
|
||||
let baseProgress = 0;
|
||||
for (let i = 0; i < phaseIndex; i++) {
|
||||
baseProgress += INSTALL_PHASES[i].weight;
|
||||
}
|
||||
const currentPhaseContribution =
|
||||
(INSTALL_PHASES[phaseIndex].weight * phaseProgress) / 100;
|
||||
return Math.round(
|
||||
((baseProgress + currentPhaseContribution) / TOTAL_WEIGHT) * 100
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install package from local tarball with progress bar
|
||||
*/
|
||||
async function installFromTarball(tarballPath: string): Promise<boolean> {
|
||||
// Create progress bar
|
||||
const progressBar = new cliProgress.SingleBar(
|
||||
{
|
||||
format: `${chalk.blue('Installing')} ${chalk.cyan('{bar}')} {percentage}% | {phase}`,
|
||||
barCompleteChar: '\u2588',
|
||||
barIncompleteChar: '\u2591',
|
||||
hideCursor: true,
|
||||
clearOnComplete: true
|
||||
},
|
||||
cliProgress.Presets.shades_classic
|
||||
);
|
||||
|
||||
progressBar.start(100, 0, { phase: INSTALL_PHASES[0].name });
|
||||
|
||||
let currentPhaseIndex = 0;
|
||||
let currentProgress = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Smooth progress animation within phases
|
||||
const progressInterval = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
// Estimate phase based on time (fallback when npm is silent)
|
||||
// Assume ~10 seconds total install time, distributed by phase weights
|
||||
const estimatedTotalTime = 10000; // 10 seconds estimate
|
||||
let timeBasedPhase = 0;
|
||||
let accumulatedTime = 0;
|
||||
|
||||
for (let i = 0; i < INSTALL_PHASES.length; i++) {
|
||||
const phaseTime =
|
||||
(INSTALL_PHASES[i].weight / TOTAL_WEIGHT) * estimatedTotalTime;
|
||||
if (elapsed < accumulatedTime + phaseTime) {
|
||||
timeBasedPhase = i;
|
||||
break;
|
||||
}
|
||||
accumulatedTime += phaseTime;
|
||||
timeBasedPhase = i;
|
||||
}
|
||||
|
||||
// Use time-based phase if we haven't detected a newer phase from npm output
|
||||
if (timeBasedPhase > currentPhaseIndex) {
|
||||
currentPhaseIndex = timeBasedPhase;
|
||||
}
|
||||
|
||||
// Calculate sub-progress within current phase (smooth animation)
|
||||
const phaseStartTime =
|
||||
(INSTALL_PHASES.slice(0, currentPhaseIndex).reduce(
|
||||
(sum, p) => sum + p.weight,
|
||||
0
|
||||
) /
|
||||
TOTAL_WEIGHT) *
|
||||
estimatedTotalTime;
|
||||
const phaseDuration =
|
||||
(INSTALL_PHASES[currentPhaseIndex].weight / TOTAL_WEIGHT) *
|
||||
estimatedTotalTime;
|
||||
const phaseElapsed = elapsed - phaseStartTime;
|
||||
const phaseProgress = Math.min((phaseElapsed / phaseDuration) * 100, 95); // Cap at 95% within phase
|
||||
|
||||
const newProgress = calculateProgress(currentPhaseIndex, phaseProgress);
|
||||
|
||||
// Only update if progress increased (never go backwards)
|
||||
if (newProgress > currentProgress) {
|
||||
currentProgress = newProgress;
|
||||
progressBar.update(currentProgress, {
|
||||
phase: INSTALL_PHASES[currentPhaseIndex].name
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const installProcess = spawn(
|
||||
'npm',
|
||||
['install', '-g', tarballPath, '--no-fund', '--no-audit'],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
|
||||
let errorOutput = '';
|
||||
|
||||
// Parse stdout for progress hints
|
||||
installProcess.stdout.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
const detectedPhase = parseNpmPhaseIndex(output);
|
||||
if (detectedPhase > currentPhaseIndex) {
|
||||
currentPhaseIndex = detectedPhase;
|
||||
const newProgress = calculateProgress(currentPhaseIndex, 0);
|
||||
if (newProgress > currentProgress) {
|
||||
currentProgress = newProgress;
|
||||
progressBar.update(currentProgress, {
|
||||
phase: INSTALL_PHASES[currentPhaseIndex].name
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
installProcess.stderr.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
errorOutput += output;
|
||||
|
||||
// npm often writes progress to stderr
|
||||
const detectedPhase = parseNpmPhaseIndex(output);
|
||||
if (detectedPhase > currentPhaseIndex) {
|
||||
currentPhaseIndex = detectedPhase;
|
||||
const newProgress = calculateProgress(currentPhaseIndex, 0);
|
||||
if (newProgress > currentProgress) {
|
||||
currentProgress = newProgress;
|
||||
progressBar.update(currentProgress, {
|
||||
phase: INSTALL_PHASES[currentPhaseIndex].name
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
installProcess.on('close', (code) => {
|
||||
clearInterval(progressInterval);
|
||||
|
||||
// Complete the progress bar
|
||||
progressBar.update(100, { phase: 'Complete' });
|
||||
progressBar.stop();
|
||||
|
||||
// Cleanup tarball
|
||||
fs.unlink(tarballPath, () => {});
|
||||
|
||||
if (code === 0) {
|
||||
console.log(
|
||||
chalk.green('✔') + chalk.green(' Update installed successfully')
|
||||
);
|
||||
resolve(true);
|
||||
} else {
|
||||
console.log(chalk.red('✖') + chalk.red(' Installation failed'));
|
||||
if (errorOutput) {
|
||||
// Only show actual errors, not progress messages
|
||||
const actualErrors = errorOutput
|
||||
.split('\n')
|
||||
.filter(
|
||||
(line) =>
|
||||
line.includes('ERR') ||
|
||||
line.includes('error') ||
|
||||
line.includes('WARN')
|
||||
)
|
||||
.join('\n')
|
||||
.trim();
|
||||
if (actualErrors) {
|
||||
console.log(chalk.dim(`Error: ${actualErrors}`));
|
||||
}
|
||||
}
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
installProcess.on('error', (error) => {
|
||||
clearInterval(progressInterval);
|
||||
progressBar.stop();
|
||||
|
||||
// Cleanup tarball
|
||||
fs.unlink(tarballPath, () => {});
|
||||
console.log(chalk.red('✖') + chalk.red(' Installation failed'));
|
||||
console.log(chalk.red('Error:'), error.message);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: Direct npm install without progress bar
|
||||
*/
|
||||
async function performDirectNpmInstall(
|
||||
latestVersion: string
|
||||
): Promise<boolean> {
|
||||
const spinner = ora({
|
||||
text: chalk.blue(
|
||||
`Updating task-master-ai to version ${chalk.green(latestVersion)}`
|
||||
),
|
||||
spinner: 'dots',
|
||||
color: 'blue'
|
||||
}).start();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const updateProcess = spawn(
|
||||
'npm',
|
||||
[
|
||||
'install',
|
||||
'-g',
|
||||
`task-master-ai@${latestVersion}`,
|
||||
'--no-fund',
|
||||
'--no-audit',
|
||||
'--loglevel=warn'
|
||||
],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}
|
||||
);
|
||||
|
||||
let errorOutput = '';
|
||||
|
||||
updateProcess.stdout.on('data', () => {
|
||||
spinner.text = chalk.blue(
|
||||
`Installing task-master-ai@${latestVersion}...`
|
||||
);
|
||||
});
|
||||
|
||||
updateProcess.stderr.on('data', (data) => {
|
||||
errorOutput += data.toString();
|
||||
});
|
||||
|
||||
updateProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
spinner.succeed(
|
||||
chalk.green(
|
||||
`Successfully updated to version ${chalk.bold(latestVersion)}`
|
||||
)
|
||||
);
|
||||
resolve(true);
|
||||
} else {
|
||||
spinner.fail(chalk.red('Auto-update failed'));
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`Please run manually: npm install -g task-master-ai@${latestVersion}`
|
||||
)
|
||||
);
|
||||
if (errorOutput) {
|
||||
console.log(chalk.dim(`Error: ${errorOutput.trim()}`));
|
||||
}
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
updateProcess.on('error', (error) => {
|
||||
spinner.fail(chalk.red('Auto-update failed'));
|
||||
console.log(chalk.red('Error:'), error.message);
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`Please run manually: npm install -g task-master-ai@${latestVersion}`
|
||||
)
|
||||
);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically update task-master-ai to the latest version
|
||||
*/
|
||||
export async function performAutoUpdate(
|
||||
latestVersion: string
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
process.env.TASKMASTER_SKIP_AUTO_UPDATE === '1' ||
|
||||
process.env.CI ||
|
||||
process.env.NODE_ENV === 'test'
|
||||
) {
|
||||
const reason =
|
||||
process.env.TASKMASTER_SKIP_AUTO_UPDATE === '1'
|
||||
? 'TASKMASTER_SKIP_AUTO_UPDATE=1'
|
||||
: process.env.CI
|
||||
? 'CI environment'
|
||||
: 'NODE_ENV=test';
|
||||
console.log(chalk.dim(`Skipping auto-update (${reason})`));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetch tarball info from npm registry
|
||||
const tarballInfo = await fetchTarballInfo(latestVersion);
|
||||
|
||||
if (!tarballInfo) {
|
||||
// Fall back to direct npm install if we can't get tarball info
|
||||
return performDirectNpmInstall(latestVersion);
|
||||
}
|
||||
|
||||
// Create temp directory for tarball
|
||||
const tempDir = os.tmpdir();
|
||||
const tarballPath = path.join(tempDir, `task-master-ai-${latestVersion}.tgz`);
|
||||
|
||||
// Download tarball with progress
|
||||
const downloadSuccess = await downloadTarballWithProgress(
|
||||
tarballInfo.url,
|
||||
tarballPath,
|
||||
latestVersion
|
||||
);
|
||||
|
||||
if (!downloadSuccess) {
|
||||
// Fall back to direct npm install on download failure
|
||||
console.log(chalk.dim('Falling back to npm install...'));
|
||||
return performDirectNpmInstall(latestVersion);
|
||||
}
|
||||
|
||||
// Install from tarball
|
||||
const installSuccess = await installFromTarball(tarballPath);
|
||||
|
||||
if (!installSuccess) {
|
||||
console.log(
|
||||
chalk.cyan(
|
||||
`Please run manually: npm install -g task-master-ai@${latestVersion}`
|
||||
)
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.green(`Successfully updated to version ${chalk.bold(latestVersion)}`)
|
||||
);
|
||||
return true;
|
||||
}
|
||||
42
apps/cli/src/utils/auto-update/restart.ts
Normal file
42
apps/cli/src/utils/auto-update/restart.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @fileoverview CLI restart functionality after update
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import process from 'process';
|
||||
|
||||
import chalk from 'chalk';
|
||||
|
||||
/**
|
||||
* Restart the CLI with the newly installed version
|
||||
* @param argv - Original command-line arguments (process.argv)
|
||||
*/
|
||||
export function restartWithNewVersion(argv: string[]): void {
|
||||
const args = argv.slice(2); // Remove 'node' and script path
|
||||
|
||||
console.log(chalk.dim('Restarting with updated version...\n'));
|
||||
|
||||
// Spawn the updated task-master command
|
||||
const child = spawn('task-master', args, {
|
||||
stdio: 'inherit', // Inherit stdin/stdout/stderr so it looks seamless
|
||||
detached: false,
|
||||
shell: process.platform === 'win32' // Windows compatibility
|
||||
});
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error(
|
||||
chalk.red('Failed to restart with new version:'),
|
||||
error.message
|
||||
);
|
||||
console.log(chalk.yellow('Please run your command again manually.'));
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
15
apps/cli/src/utils/auto-update/types.ts
Normal file
15
apps/cli/src/utils/auto-update/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @fileoverview Type definitions for auto-update functionality
|
||||
*/
|
||||
|
||||
export interface UpdateInfo {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
needsUpdate: boolean;
|
||||
highlights?: string[];
|
||||
}
|
||||
|
||||
export interface TarballInfo {
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
50
apps/cli/src/utils/auto-update/version.ts
Normal file
50
apps/cli/src/utils/auto-update/version.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @fileoverview Version comparison and retrieval utilities
|
||||
*/
|
||||
|
||||
import process from 'process';
|
||||
|
||||
/**
|
||||
* Get current version from build-time injected environment variable
|
||||
*/
|
||||
export function getCurrentVersion(): string {
|
||||
// Version is injected at build time via TM_PUBLIC_VERSION
|
||||
const version = process.env.TM_PUBLIC_VERSION;
|
||||
if (version && version !== 'unknown') {
|
||||
return version;
|
||||
}
|
||||
|
||||
// Fallback for development or if injection failed
|
||||
console.warn('Could not read version from TM_PUBLIC_VERSION, using fallback');
|
||||
return '0.0.0';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare semantic versions with proper pre-release handling
|
||||
* @param v1 - First version
|
||||
* @param v2 - Second version
|
||||
* @returns -1 if v1 < v2, 0 if v1 = v2, 1 if v1 > v2
|
||||
*/
|
||||
export function compareVersions(v1: string, v2: string): number {
|
||||
const toParts = (v: string) => {
|
||||
const [core, pre = ''] = v.split('-', 2);
|
||||
const nums = core.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
||||
return { nums, pre };
|
||||
};
|
||||
|
||||
const a = toParts(v1);
|
||||
const b = toParts(v2);
|
||||
const len = Math.max(a.nums.length, b.nums.length);
|
||||
|
||||
// Compare numeric parts
|
||||
for (let i = 0; i < len; i++) {
|
||||
const d = (a.nums[i] || 0) - (b.nums[i] || 0);
|
||||
if (d !== 0) return d < 0 ? -1 : 1;
|
||||
}
|
||||
|
||||
// Handle pre-release comparison
|
||||
if (a.pre && !b.pre) return -1; // prerelease < release
|
||||
if (!a.pre && b.pre) return 1; // release > prerelease
|
||||
if (a.pre === b.pre) return 0; // same or both empty
|
||||
return a.pre < b.pre ? -1 : 1; // basic prerelease tie-break
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export {
|
||||
displayUpgradeNotification,
|
||||
compareVersions,
|
||||
restartWithNewVersion
|
||||
} from './auto-update.js';
|
||||
} from './auto-update/index.js';
|
||||
|
||||
// Display helpers (command-specific helpers)
|
||||
export { displayCommandHeader } from './display-helpers.js';
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -117,6 +117,7 @@
|
||||
"@tm/core": "*",
|
||||
"boxen": "^8.0.1",
|
||||
"chalk": "5.6.2",
|
||||
"cli-progress": "^3.12.0",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^12.1.0",
|
||||
"inquirer": "^12.5.0",
|
||||
@@ -124,6 +125,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"@types/inquirer": "^9.0.3",
|
||||
"@types/node": "^22.10.5",
|
||||
"@vitest/coverage-v8": "^4.0.10",
|
||||
@@ -12082,6 +12084,16 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cli-progress": {
|
||||
"version": "3.11.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz",
|
||||
"integrity": "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
|
||||
Reference in New Issue
Block a user