feat: improve tm auto-update display

This commit is contained in:
Ralph Khreish
2025-11-24 23:57:45 +01:00
parent ff5d63533c
commit 277218229e
13 changed files with 972 additions and 410 deletions

View File

@@ -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",

View File

@@ -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);
});
}

View 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 [];
}
}

View 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();
});
}

View 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);
}

View 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();
});
}

View 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';

View 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;
}

View 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);
});
}

View 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;
}

View 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
}

View File

@@ -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
View File

@@ -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",