diff --git a/.changeset/hip-pots-study.md b/.changeset/hip-pots-study.md new file mode 100644 index 00000000..ad87130f --- /dev/null +++ b/.changeset/hip-pots-study.md @@ -0,0 +1,5 @@ +--- +"task-master-ai": patch +--- + +Improve CLI startup speed by 2x diff --git a/apps/cli/src/utils/auto-update/check-update.ts b/apps/cli/src/utils/auto-update/check-update.ts index f5a5a7b0..5cac9a28 100644 --- a/apps/cli/src/utils/auto-update/check-update.ts +++ b/apps/cli/src/utils/auto-update/check-update.ts @@ -1,88 +1,187 @@ /** - * @fileoverview Check npm registry for package updates + * @fileoverview Check npm registry for package updates with caching + * + * Uses a simple file-based cache in the OS temp directory to avoid + * hitting npm on every CLI invocation. Cache expires after 1 hour. */ +import fs from 'node:fs'; import https from 'https'; +import os from 'node:os'; +import path from 'node:path'; import { fetchChangelogHighlights } from './changelog.js'; import type { UpdateInfo } from './types.js'; import { compareVersions, getCurrentVersion } from './version.js'; +// ============================================================================ +// Cache Configuration +// ============================================================================ + +/** Cache TTL: 1 hour in milliseconds */ +const CACHE_TTL_MS = 60 * 60 * 1000; + +/** Cache file name */ +const CACHE_FILENAME = 'taskmaster-update-cache.json'; + +interface UpdateCache { + timestamp: number; + latestVersion: string; + highlights?: string[]; +} + +// ============================================================================ +// Cache Operations (Single Responsibility: cache I/O) +// ============================================================================ + +/** + * Get the path to the update cache file in OS temp directory + */ +const getCachePath = (): string => path.join(os.tmpdir(), CACHE_FILENAME); + +/** + * Read cached update info if still valid + * @returns Cached data or null if expired/missing/invalid + */ +function readCache(): UpdateCache | null { + try { + const cachePath = getCachePath(); + if (!fs.existsSync(cachePath)) return null; + + const data: UpdateCache = JSON.parse(fs.readFileSync(cachePath, 'utf-8')); + const isExpired = Date.now() - data.timestamp > CACHE_TTL_MS; + + return isExpired ? null : data; + } catch { + return null; + } +} + +/** + * Write update info to cache + */ +function writeCache(latestVersion: string, highlights?: string[]): void { + try { + fs.writeFileSync( + getCachePath(), + JSON.stringify( + { + timestamp: Date.now(), + latestVersion, + highlights + } satisfies UpdateCache, + null, + 2 + ) + ); + } catch { + // Cache write failures are non-critical - silently ignore + } +} + +// ============================================================================ +// NPM Registry Operations (Single Responsibility: npm API) +// ============================================================================ + +/** Request timeout for npm registry */ +const NPM_TIMEOUT_MS = 3000; + +/** + * Fetch latest version from npm registry + * @returns Latest version string or null on failure + */ +function fetchLatestVersion(currentVersion: string): Promise { + return new Promise((resolve) => { + const req = https.request( + { + hostname: 'registry.npmjs.org', + path: '/task-master-ai', + method: 'GET', + headers: { + Accept: 'application/vnd.npm.install-v1+json', + 'User-Agent': `task-master-ai/${currentVersion}` + } + }, + (res) => { + let data = ''; + res.on('data', (chunk) => (data += chunk)); + res.on('end', () => { + try { + if (res.statusCode !== 200) { + resolve(null); + return; + } + const npmData = JSON.parse(data); + resolve(npmData['dist-tags']?.latest || null); + } catch { + resolve(null); + } + }); + } + ); + + req.on('error', () => resolve(null)); + req.setTimeout(NPM_TIMEOUT_MS, () => { + req.destroy(); + resolve(null); + }); + req.end(); + }); +} + +// ============================================================================ +// Public API +// ============================================================================ + +/** + * Build UpdateInfo response + */ +function buildUpdateInfo( + currentVersion: string, + latestVersion: string, + highlights?: string[] +): UpdateInfo { + return { + currentVersion, + latestVersion, + needsUpdate: compareVersions(currentVersion, latestVersion) < 0, + highlights + }; +} + /** * Check for newer version of task-master-ai + * Uses a 1-hour cache to avoid hitting npm on every CLI invocation */ export async function checkForUpdate( currentVersionOverride?: string ): Promise { 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}` - } - }; + // Return cached result if valid + const cached = readCache(); + if (cached) { + return buildUpdateInfo( + currentVersion, + cached.latestVersion, + cached.highlights + ); + } - const req = https.request(options, (res) => { - let data = ''; + // Fetch from npm registry + const latestVersion = await fetchLatestVersion(currentVersion); + if (!latestVersion) { + return buildUpdateInfo(currentVersion, currentVersion); + } - res.on('data', (chunk) => { - data += chunk; - }); + // Fetch changelog highlights if update available + const needsUpdate = compareVersions(currentVersion, latestVersion) < 0; + const highlights = needsUpdate + ? await fetchChangelogHighlights(latestVersion) + : undefined; - 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; + // Cache result + writeCache(latestVersion, highlights); - 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(); - }); + return buildUpdateInfo(currentVersion, latestVersion, highlights); } diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 7c0a425b..44c8d31f 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -4,6 +4,10 @@ import rootConfig from '../../vitest.config'; /** * CLI package Vitest configuration * Extends root config with CLI-specific settings + * + * Integration tests (.test.ts) spawn CLI processes and need more time. + * The 30s timeout is reasonable now that auto-update network calls are skipped + * when TASKMASTER_SKIP_AUTO_UPDATE=1 or NODE_ENV=test. */ export default mergeConfig( rootConfig, @@ -15,7 +19,10 @@ export default mergeConfig( 'tests/**/*.spec.ts', 'src/**/*.test.ts', 'src/**/*.spec.ts' - ] + ], + // Integration tests spawn CLI processes - 30s is reasonable with optimized startup + testTimeout: 30000, + hookTimeout: 15000 } }) ); diff --git a/scripts/modules/commands.js b/scripts/modules/commands.js index b8ac63c2..e3ca7061 100644 --- a/scripts/modules/commands.js +++ b/scripts/modules/commands.js @@ -5402,9 +5402,16 @@ async function runCLI(argv = process.argv) { displayBanner(); } - // Check for updates BEFORE executing the command + // Check for updates BEFORE executing the command (skip entirely in test/CI mode) + const skipAutoUpdate = + process.env.TASKMASTER_SKIP_AUTO_UPDATE === '1' || + process.env.CI || + process.env.NODE_ENV === 'test'; + const currentVersion = getTaskMasterVersion(); - const updateInfo = await checkForUpdate(currentVersion); + const updateInfo = skipAutoUpdate + ? { currentVersion, latestVersion: currentVersion, needsUpdate: false } + : await checkForUpdate(currentVersion); if (updateInfo.needsUpdate) { // Display the upgrade notification first