diff --git a/.changeset/major-kiwis-itch.md b/.changeset/major-kiwis-itch.md new file mode 100644 index 000000000..95b4231a8 --- /dev/null +++ b/.changeset/major-kiwis-itch.md @@ -0,0 +1,5 @@ +--- +"@workflow/cli": patch +--- + +Display a notice when using an outdated version of the workflow package diff --git a/packages/cli/src/lib/inspect/setup.ts b/packages/cli/src/lib/inspect/setup.ts index 52f09d8cc..92c93bdb2 100644 --- a/packages/cli/src/lib/inspect/setup.ts +++ b/packages/cli/src/lib/inspect/setup.ts @@ -1,13 +1,28 @@ +import { join } from 'node:path'; import { createWorld } from '@workflow/core/runtime'; import chalk from 'chalk'; import terminalLink from 'terminal-link'; +import XDGAppPaths from 'xdg-app-paths'; import { logger, setJsonMode, setVerboseMode } from '../config/log.js'; +import { checkForUpdateCached } from '../update-check.js'; import { inferLocalWorldEnvVars, inferVercelEnvVars, writeEnvVars, } from './env.js'; +// Get XDG-compliant cache directory for workflow +const getXDGAppPaths = (app: string) => { + return ( + XDGAppPaths as unknown as (app: string) => { dataDirs: () => string[] } + )(app); +}; + +const getWorkflowCacheDir = (): string => { + const dirs = getXDGAppPaths('workflow').dataDirs(); + return dirs[0]; +}; + /** * Setup CLI world configuration. * If throwOnConfigError is false, will return null world with the error message @@ -29,17 +44,45 @@ export const setupCliWorld = async ( setJsonMode(Boolean(flags.json)); setVerboseMode(Boolean(flags.verbose)); + // Check for updates + const cacheFile = join(getWorkflowCacheDir(), 'version-check.json'); + const updateCheck = await checkForUpdateCached(version, cacheFile); + const withAnsiLinks = flags.json ? false : true; const docsUrl = withAnsiLinks ? terminalLink('https://useworkflow.dev/', 'https://useworkflow.dev/') : 'https://useworkflow.dev/'; - logger.showBox( - 'green', + // Prepare showBox lines + const boxLines = [ `Workflow CLI v${version}`, `Docs at ${docsUrl}`, - chalk.yellow('This is a beta release') - ); + chalk.yellow('This is a beta release'), + ]; + + // Add update message if available + if (updateCheck.needsUpdate && updateCheck.latestVersion) { + boxLines.push( + '', + chalk.cyan( + `Update available: ${updateCheck.currentVersion} → ${updateCheck.latestVersion}` + ), + // Note that we're suggesting install "latest" instead of the release tag that the user is + // on, because we currently tag beta releases as "latest". After GA, we need to adjust + // this to install the release tag that the user is on. + chalk.gray( + `Run: \`[npm|bun|pnpm] i workflow@${updateCheck.latestVersion}\`` + ), + chalk.gray( + terminalLink( + 'View changelog', + 'https://github.com/vercel/workflow/releases' + ) + ) + ); + } + + logger.showBox('green', ...boxLines); logger.debug('Inferring env vars, backend:', flags.backend); writeEnvVars({ diff --git a/packages/cli/src/lib/update-check.ts b/packages/cli/src/lib/update-check.ts new file mode 100644 index 000000000..730271be5 --- /dev/null +++ b/packages/cli/src/lib/update-check.ts @@ -0,0 +1,233 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { logger } from './config/log.js'; + +// Constants +const PACKAGE_NAME = '@workflow/cli'; +const NPM_REGISTRY = 'https://registry.npmjs.org'; +const CACHE_DURATION_MS = 3 * 24 * 60 * 60 * 1000; // 3 days +const REQUEST_TIMEOUT_MS = 5000; + +interface VersionCheckResult { + currentVersion: string; + latestVersion?: string; + needsUpdate: boolean; +} + +interface CachedVersionData { + currentVersion: string; + latestVersion: string; + timestamp: number; +} + +/** + * Compare two semver versions including pre-release tags + * Returns true if version a is greater than version b + */ +function compareVersions(a: string, b: string): boolean { + const parseVersion = (v: string) => { + const [base, prerelease] = v.split('-'); + const parts = base.split('.').map(Number); + return { parts, prerelease }; + }; + + const versionA = parseVersion(a); + const versionB = parseVersion(b); + + // Compare major, minor, patch + for (let i = 0; i < 3; i++) { + if (versionA.parts[i] > versionB.parts[i]) return true; + if (versionA.parts[i] < versionB.parts[i]) return false; + } + + // If versions are equal up to patch level, check prerelease + // No prerelease is considered greater than prerelease + if (!versionA.prerelease && versionB.prerelease) return true; + if (versionA.prerelease && !versionB.prerelease) return false; + + // Both have prereleases or both don't - they're equal + if (versionA.prerelease && versionB.prerelease) { + return versionA.prerelease > versionB.prerelease; + } + + return false; +} + +/** + * Fetch the latest version from npm registry + */ +async function fetchLatestVersion( + currentVersion: string +): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + + const url = `${NPM_REGISTRY}/${PACKAGE_NAME}`; + logger.debug(`Checking for updates at ${url}`); + + const response = await fetch(url, { + headers: { + Accept: 'application/json', + }, + signal: controller.signal, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.debug( + `Failed to fetch package info: ${response.status} ${response.statusText}` + ); + return null; + } + + const data = (await response.json()) as { + 'dist-tags': { [tag: string]: string }; + }; + + // Always use 'latest' tag - even beta versions are published as latest + const latestVersion = data['dist-tags']['latest']; + if (!latestVersion) { + logger.debug('No latest version found in registry'); + return null; + } + + logger.debug(`Current: ${currentVersion}, Latest: ${latestVersion}`); + return latestVersion; + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + logger.debug('Version check timed out after 5 seconds'); + } else { + logger.debug(`Error fetching version: ${error}`); + } + return null; + } +} + +/** + * Check if there's a new version available + * Returns the current and latest version if an update is available + */ +export async function checkForUpdate( + currentVersion: string +): Promise { + const latestVersion = await fetchLatestVersion(currentVersion); + + if (!latestVersion) { + return { + currentVersion, + needsUpdate: false, + }; + } + + const needsUpdate = compareVersions(latestVersion, currentVersion); + + return { + currentVersion, + latestVersion, + needsUpdate, + }; +} + +/** + * Read cached version data from file + */ +async function readCache(cacheFile: string): Promise { + try { + const content = await readFile(cacheFile, 'utf-8'); + const data = JSON.parse(content) as CachedVersionData; + return data; + } catch { + return null; + } +} + +/** + * Write version data to cache file + */ +async function writeCache( + cacheFile: string, + data: CachedVersionData +): Promise { + try { + await mkdir(dirname(cacheFile), { recursive: true }); + await writeFile(cacheFile, JSON.stringify(data, null, 2)); + } catch (error) { + logger.debug(`Failed to write version cache: ${error}`); + } +} + +/** + * Check if cache is still valid + */ +async function isCacheValid( + cacheFile: string, + currentVersion: string +): Promise { + try { + const cached = await readCache(cacheFile); + if (!cached) return false; + + // Cache is invalid if version changed + if (cached.currentVersion !== currentVersion) { + logger.debug('Version changed, cache invalidated'); + return false; + } + + // Check if cache is still fresh + const now = Date.now(); + const age = now - cached.timestamp; + const isValid = age < CACHE_DURATION_MS; + + if (!isValid) { + logger.debug( + `Cache expired (age: ${Math.floor(age / 1000 / 60)} minutes)` + ); + } + + return isValid; + } catch { + return false; + } +} + +/** + * Check for updates with filesystem caching + * Cache is valid unless the local version changes + */ +export async function checkForUpdateCached( + currentVersion: string, + cacheFile: string +): Promise { + // Check if cache is valid + if (await isCacheValid(cacheFile, currentVersion)) { + logger.debug('Using cached version check result'); + const cached = await readCache(cacheFile); + if (cached) { + return { + currentVersion: cached.currentVersion, + latestVersion: cached.latestVersion, + needsUpdate: compareVersions( + cached.latestVersion, + cached.currentVersion + ), + }; + } + } + + // Perform fresh check + logger.debug('Performing fresh version check'); + const result = await checkForUpdate(currentVersion); + + // Cache the result if we got a latest version + if (result.latestVersion) { + await writeCache(cacheFile, { + currentVersion: result.currentVersion, + latestVersion: result.latestVersion, + timestamp: Date.now(), + }); + } + + return result; +}