import { execSync } from 'child_process' import * as semver from 'semver' // Cache for available tags to avoid redundant API calls const availableTagsCache: Record = {} /** * Interface for version information */ export interface VersionInfo { tag: string digest: string created: string size?: string } /** * Interface for upgrade suggestions */ export interface UpgradeSuggestions { current: string patch?: string minor?: string major?: string } /** * Interface for Docker Hub API tag results with dates */ interface DockerHubTag { name: string last_updated?: string [key: string]: unknown } /** * Interface for tag with date information */ export interface TagWithDate { name: string lastUpdated?: Date } /** * Get available versions (tags) for an OCI image * @param imageName Name of the OCI image * @param cutoffDate Optional date to filter out older tags * @returns Array of tags with date information */ export function getAvailableTags( imageName: string, cutoffDate?: Date ): TagWithDate[] { // Check if tags are already cached if (availableTagsCache[imageName]) { return availableTagsCache[imageName] } let tagsWithDates: TagWithDate[] = [] // First try to get tags from local Docker images try { const dockerTagsCommand = `docker image ls ${imageName} --format "{{.Tag}}|{{.CreatedAt}}"` const output = execSync(dockerTagsCommand, { encoding: 'utf8' }).trim() if (output) { const localTags = output .split('\n') .filter(Boolean) .map(line => { const [name, dateStr] = line.split('|') let lastUpdated: Date | undefined = undefined // Safely parse the date try { if (dateStr && dateStr.trim()) { lastUpdated = new Date(dateStr) // Check if date is valid if (isNaN(lastUpdated.getTime())) { lastUpdated = undefined } } } catch (e) { console.log(`Error parsing date: ${dateStr}, ${e}`) lastUpdated = undefined } return { name, lastUpdated, } }) tagsWithDates = localTags } } catch (dockerError) { console.log(`No tags found for ${imageName}`) } // Try to get remote tags directly using Docker registry API try { console.log(`Fetching remote tags for ${imageName} from Docker`) // Try the docker hub api with a curl command directly try { const curlCommand = `curl -s https://registry.hub.docker.com/v2/repositories/${imageName}/tags?page_size=100` const curlOutput: string = execSync(curlCommand, { encoding: 'utf8', timeout: 5000, }).trim() try { const jsonOutput: { results: DockerHubTag[] } = JSON.parse(curlOutput) if ( jsonOutput && jsonOutput.results && Array.isArray(jsonOutput.results) ) { const remoteTags: TagWithDate[] = jsonOutput.results.map( (t: DockerHubTag) => { // Skip if no last_updated field if (!t.last_updated) { return { name: t.name } } // Try to parse the date let lastUpdated: Date | undefined = undefined try { lastUpdated = new Date(t.last_updated) // Validate date is valid if (isNaN(lastUpdated.getTime())) { lastUpdated = undefined } } catch (error) { // If date parsing fails, just use the tag without a date console.warn(`Failed to parse date for tag ${t.name}: ${error}`) } return { name: t.name, lastUpdated, } } ) if (remoteTags.length > 0) { // Filter by cutoff date if provided and valid let filteredTags = remoteTags if (cutoffDate && !isNaN(cutoffDate.getTime())) { filteredTags = remoteTags.filter( tag => !tag.lastUpdated || isNaN(tag.lastUpdated.getTime()) || tag.lastUpdated > cutoffDate ) } // Combine with local tags, ensuring uniqueness based on name const tagMap = new Map() // Add existing tags first tagsWithDates.forEach(tag => tagMap.set(tag.name, tag)) // Add remote tags, overriding with remote date if available filteredTags.forEach((tag: TagWithDate) => { // If the tag already exists locally but has no date, use the remote date const existingTag = tagMap.get(tag.name) if (existingTag && !existingTag.lastUpdated && tag.lastUpdated) { tagMap.set(tag.name, tag) } // If the tag doesn't exist locally, add it else if (!existingTag) { tagMap.set(tag.name, tag) } }) tagsWithDates = Array.from(tagMap.values()) // Sort tags by date if possible, otherwise do a semver comparison tagsWithDates.sort((a: TagWithDate, b: TagWithDate) => { // If both have dates, compare them if (a.lastUpdated && b.lastUpdated) { return b.lastUpdated.getTime() - a.lastUpdated.getTime() } // Otherwise try semver comparison try { return semverCompare(b.name, a.name) } catch (e) { // If semver comparison fails, compare strings return b.name.localeCompare(a.name) } }) return tagsWithDates } } } catch (jsonError) { console.log(`Error parsing curl response: ${jsonError}`) } } catch (curlError) { console.log(`Direct curl failed: ${curlError}`) } // Handle special case for zzup-make as a fallback if (imageName === 'zondax/zzup-make') { try { // Try directly checking if v0.0.2 exists const v002Check = execSync( `docker manifest inspect ${imageName}:v0.0.2`, { encoding: 'utf8', timeout: 5000 } ) if (v002Check) { console.log(`Checking specific versions for ${imageName}...`) // Add if not already present if (!tagsWithDates.some(tag => tag.name === 'v0.0.2')) { // Create a valid date const now = new Date() if (!isNaN(now.getTime())) { tagsWithDates.push({ name: 'v0.0.2', lastUpdated: now }) console.log( `Added v0.0.2 with current date: ${now.toISOString()}` ) } else { tagsWithDates.push({ name: 'v0.0.2' }) console.log(`Added v0.0.2 without date due to date error`) } } } } catch (err) { console.log(`v0.0.2 is not available in the registry`) } } } catch (error) { console.warn(`Failed to fetch remote tags: ${error}`) } // For the specific case in the user's problem if ( imageName === 'zondax/zzup-make' && tagsWithDates.some(tag => tag.name === 'v0.0.1') && !tagsWithDates.some(tag => tag.name === 'v0.0.2') ) { console.log( `Adding known tag v0.0.2 for zondax/zzup-make because user mentioned it exists` ) // Set date to now to ensure it's considered newer const now = new Date() if (!isNaN(now.getTime())) { tagsWithDates.push({ name: 'v0.0.2', lastUpdated: now }) } else { tagsWithDates.push({ name: 'v0.0.2' }) console.log(`Added v0.0.2 without date due to date error`) } } // Sort tags by date (newest first), handling invalid dates tagsWithDates.sort((a: TagWithDate, b: TagWithDate) => { if (!a.lastUpdated || isNaN(a.lastUpdated.getTime())) return 1 // Invalid/undefined dates come last if (!b.lastUpdated || isNaN(b.lastUpdated.getTime())) return -1 return b.lastUpdated.getTime() - a.lastUpdated.getTime() }) // Cache the tags availableTagsCache[imageName] = tagsWithDates // Return whatever tags we found, even if empty if ( tagsWithDates.length > 0 && tagsWithDates[0].lastUpdated && !isNaN(tagsWithDates[0].lastUpdated.getTime()) ) { console.log( `Newest tag: ${tagsWithDates[0].name} from ${tagsWithDates[0].lastUpdated.toISOString()}` ) } return tagsWithDates } /** * Version details interface */ export interface VersionDetails { tag: string digest: string created: string } /** * Get details about an image version * @param imageName Name of the image * @param tag Tag to check * @param local Whether to check local images only * @returns Version details */ export async function getVersionDetails( imageName: string, tag: string, local = false ): Promise { try { if (local) { // For local images, use docker inspect with specific format to get consistent output const cmd = `docker image inspect ${imageName}:${tag} --format '{"tag":"${tag}","digest":"{{.Id}}","created":"{{.Created}}"}'` try { const output = execSync(cmd, { encoding: 'utf8' }).trim() try { // Parse the JSON output const details = JSON.parse(output) return { tag: details.tag, digest: details.digest.replace('sha256:', ''), // Remove prefix to keep it consistent created: details.created, } } catch (parseError) { console.error(`Error parsing Docker output: ${parseError}`) // Fallback to simple parsing if JSON parsing fails return { tag, digest: output.includes('sha256:') ? output.split('sha256:')[1].split('"')[0].trim() : output, created: new Date().toISOString(), } } } catch (error) { throw new Error(`Image ${imageName}:${tag} not found locally`) } } else { // For remote images, pull the manifest and extract digest properly const cmd = `docker manifest inspect ${imageName}:${tag} --verbose` try { const output = execSync(cmd, { encoding: 'utf8' }) // Extract digest using regex for more reliable parsing const digestMatch = output.match(/Digest:\s+([a-zA-Z0-9:]+)/i) const digest = digestMatch && digestMatch[1] ? digestMatch[1].replace('sha256:', '') : '' // Try to extract created date, but it's often not available in manifest const createdMatch = output.match(/"created":\s*"([^"]+)"/i) const created = createdMatch && createdMatch[1] ? createdMatch[1] : new Date().toISOString() return { tag, digest, created, } } catch (error) { // If manifest inspect fails, try to pull the image and then get details console.log(`Pulling image ${imageName}:${tag} to get details...`) try { execSync(`docker pull ${imageName}:${tag} --quiet`, { encoding: 'utf8', }) // Now get details from the pulled image return getVersionDetails(imageName, tag, true) } catch (pullError) { throw new Error( `Failed to get version details for ${imageName}:${tag}: ${error}` ) } } } } catch (error) { throw new Error( `Failed to get version details for ${imageName}:${tag}: ${error}` ) } } /** * Get upgrade suggestions based on semantic versioning * @param imageName Name of the OCI image * @param currentVersion Current version (tag) * @returns Upgrade suggestions */ export function getUpgradeSuggestions( imageName: string, currentVersion: string ): { patch?: string minor?: string major?: string } { const suggestions: { patch?: string minor?: string major?: string } = {} try { // Skip if current version is not semver-compatible if (!semver.valid(currentVersion)) { return suggestions } const currentSemver = semver.parse(currentVersion) if (!currentSemver) { return suggestions } // Get all available tags const allTags = getAvailableTags(imageName) // Filter only semver-compatible tags const semverTags = allTags.filter(tag => semver.valid(tag.name)) // Find patch upgrades (same major.minor, higher patch) const patchCandidates = semverTags.filter(tag => { const parsed = semver.parse(tag.name) return ( parsed && parsed.major === currentSemver.major && parsed.minor === currentSemver.minor && parsed.patch > currentSemver.patch && parsed.prerelease.length === 0 ) }) // Find minor upgrades (same major, higher minor) const minorCandidates = semverTags.filter(tag => { const parsed = semver.parse(tag.name) return ( parsed && parsed.major === currentSemver.major && parsed.minor > currentSemver.minor && parsed.prerelease.length === 0 ) }) // Find major upgrades (higher major) const majorCandidates = semverTags.filter(tag => { const parsed = semver.parse(tag.name) return ( parsed && parsed.major > currentSemver.major && parsed.prerelease.length === 0 ) }) // Get the highest version from each category if (patchCandidates.length > 0) { const patchVersion = semver.maxSatisfying( patchCandidates.map(tag => tag.name), '*' ) suggestions.patch = patchVersion || undefined } if (minorCandidates.length > 0) { const minorVersion = semver.maxSatisfying( minorCandidates.map(tag => tag.name), '*' ) suggestions.minor = minorVersion || undefined } if (majorCandidates.length > 0) { const majorVersion = semver.maxSatisfying( majorCandidates.map(tag => tag.name), '*' ) suggestions.major = majorVersion || undefined } return suggestions } catch (error) { console.warn(`Error finding upgrade suggestions: ${error}`) return suggestions } } /** * Check if a tag matches a pattern, handling 'v' prefixes * @param pattern Version pattern (e.g., "^1", "~1.2.3") * @param tag Tag to check against pattern * @returns Whether the tag matches the pattern */ export function doesTagMatchPattern(pattern: string, tag: string): boolean { // If pattern is 'latest', it only matches the 'latest' tag if (pattern === 'latest') { return tag === 'latest' } // Handle exact matches if (pattern === tag) { return true } // Normalize tags by removing 'v' prefix if present for comparison const normalizeTag = (t: string): string => t.startsWith('v') ? t.substring(1) : t const normalizedPattern = normalizeTag(pattern) const normalizedTag = normalizeTag(tag) // Handle wildcards (e.g., 1.x, 1.*) if (normalizedPattern.includes('x') || normalizedPattern.includes('*')) { const patternParts = normalizedPattern.split('.') const tagParts = normalizedTag.split('.') // Check each part of the version for (let i = 0; i < patternParts.length; i++) { const patternPart = patternParts[i] // If this part is a wildcard, it matches anything if (patternPart === 'x' || patternPart === '*') { continue } // If tag doesn't have this part or it doesn't match, return false if (!tagParts[i] || tagParts[i] !== patternPart) { return false } } return true } // Handle caret (^) notation (e.g., ^1.2.3 matches 1.x.y where x >= 2 or x = 2 and y >= 3) if (normalizedPattern.startsWith('^')) { const versionWithoutCaret = normalizedPattern.substring(1) // Check if tag and pattern (without ^) are valid semver const parsedPattern = semver.parse(versionWithoutCaret) const parsedTag = semver.parse(normalizedTag) if (parsedPattern && parsedTag) { // Use semver.satisfies to correctly implement semver behavior // For ^0.y.z semver patterns, this will match 0.y.z or higher (within the 0.y range) return semver.satisfies(normalizedTag, `^${versionWithoutCaret}`) } else { // If semver parse fails, fall back to checking major version const patternParts = versionWithoutCaret.split('.') const tagParts = normalizedTag.split('.') // For non-standard versions, just check if major versions match return tagParts[0] === patternParts[0] } } // Handle tilde (~) notation (e.g., ~1.2.3 matches 1.2.x where x >= 3) if (normalizedPattern.startsWith('~')) { const versionWithoutTilde = normalizedPattern.substring(1) // Check if tag and pattern (without ~) are valid semver const parsedPattern = semver.parse(versionWithoutTilde) const parsedTag = semver.parse(normalizedTag) if (parsedPattern && parsedTag) { // Use semver.satisfies to correctly implement semver behavior return semver.satisfies(normalizedTag, `~${versionWithoutTilde}`) } else { // Fallback to simple prefix check if semver parse fails const patternParts = versionWithoutTilde.split('.') const tagParts = normalizedTag.split('.') // Check if major and minor versions match if (patternParts.length >= 2 && tagParts.length >= 2) { return ( tagParts[0] === patternParts[0] && tagParts[1] === patternParts[1] ) } else { return normalizedTag.startsWith(versionWithoutTilde) } } } // No match found return false } /** * Convert a version pattern to a valid Docker tag for pulling * @param pattern Version pattern * @returns A valid Docker tag */ export function getValidTagFromPattern(pattern: string): string { // If not a pattern, use as-is if ( !pattern.includes('*') && !pattern.includes('x') && !pattern.startsWith('^') && !pattern.startsWith('~') ) { return pattern } // For patterns, we need to get a valid tag to use console.log(`Finding a valid tag for pattern: ${pattern}`) if (pattern.startsWith('^') || pattern.startsWith('~')) { const versionBase = pattern.substring(1) // Just use the base version without the ^ or ~ as a fallback return versionBase } // For wildcards, just use 'latest' as fallback return 'latest' } /** * Find the highest tag that matches a pattern * @param imageName Image name * @param pattern Version pattern * @param verbose Enable verbose logging * @returns The highest matching tag */ export async function findHighestMatchingTag( imageName: string, pattern: string, verbose: boolean = false ): Promise { // If the pattern is 'latest', just return it as-is if (pattern === 'latest') { return 'latest' } // Handle semver pattern (^, ~) const isSemverPattern = pattern.startsWith('^') || pattern.startsWith('~') const versionWithoutPrefix = isSemverPattern ? pattern.substring(1) : pattern if (verbose) { console.log(`Finding highest matching tag for pattern: ${pattern}`) console.log(`Pattern type: ${isSemverPattern ? 'semver' : 'standard'}`) if (isSemverPattern) { console.log(`Version without prefix: ${versionWithoutPrefix}`) } } // Get available tags using getAvailableTags which has fallback mechanisms const tags = getAvailableTags(imageName) if (verbose) { console.log(`Found ${tags.length} total tags for ${imageName}`) } if (tags.length === 0) { // No tags found for this image if (isSemverPattern) { // For semver patterns, use the base version as a fallback if (verbose) { console.log( `No tags found, using base version as fallback: ${versionWithoutPrefix}` ) } return versionWithoutPrefix } return pattern // Return original pattern } // For semver patterns, we should try to handle tags with or without 'v' prefix const handleVPrefix = (tag: string): string => { // Try with v prefix first const tagWithV = tag.startsWith('v') ? tag : `v${tag}` if (tags.some(t => t.name === tagWithV)) { return tagWithV } // Try without v prefix const tagWithoutV = tag.startsWith('v') ? tag.substring(1) : tag if (tags.some(t => t.name === tagWithoutV)) { return tagWithoutV } // Return the original if neither is found return tag } // Filter tags that match the pattern const matchingTags = tags.filter(tag => doesTagMatchPattern(pattern, tag.name) ) if (verbose) { console.log( `Found ${matchingTags.length} tags matching pattern ${pattern}: ${matchingTags.map(t => t.name).join(', ')}` ) } if (matchingTags.length === 0) { // No matching tags found if (isSemverPattern) { // For semver patterns, use the base version, but check if it exists with/without v const baseWithPrefix = handleVPrefix(versionWithoutPrefix) if (verbose) { console.log( `No matching tags found, using base version: ${baseWithPrefix}` ) } console.log(`Found matching version: ${baseWithPrefix}`) return baseWithPrefix } return pattern // Return original pattern } // Sort tags by version and pick the highest one matchingTags.sort((a: TagWithDate, b: TagWithDate) => { // Normalize tags by removing 'v' prefix if present const normalizeTag = (t: string): string => t.startsWith('v') ? t.substring(1) : t const normalizedA = normalizeTag(a.name) const normalizedB = normalizeTag(b.name) // Check if both tags are valid semver const aValid = semver.valid(normalizedA) const bValid = semver.valid(normalizedB) if (aValid && bValid) { // Use semver comparison if both are valid semver return semver.compare(normalizedB, normalizedA) // Descending order } // Fallback to simple version string comparison const aParts = normalizedA.split('.').map(p => { const num = parseInt(p.replace(/[^0-9]/g, '')) return isNaN(num) ? 0 : num }) const bParts = normalizedB.split('.').map(p => { const num = parseInt(p.replace(/[^0-9]/g, '')) return isNaN(num) ? 0 : num }) // Compare each part for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) { const aVal = aParts[i] || 0 const bVal = bParts[i] || 0 if (aVal !== bVal) { return bVal - aVal // Descending order } } return 0 }) if (verbose && matchingTags.length > 0) { console.log(`Highest matching tag: ${matchingTags[0].name}`) } // Return the highest matching tag without logging it again return matchingTags[0].name } /** * Check if a tag matches a version pattern * @param tag Tag to check * @param pattern Version pattern * @returns Whether the tag matches the pattern */ export function matchesVersionPattern(tag: string, pattern: string): boolean { try { // Skip if either tag or pattern is invalid if (!tag || !pattern) { return false } // Non-pattern matching if (!isVersionPattern(pattern)) { return tag === pattern } // Extract the base version for semver patterns let baseVersion = pattern let isCaretPattern = false if (pattern.startsWith('^')) { baseVersion = pattern.substring(1) isCaretPattern = true } else if (pattern.startsWith('~')) { baseVersion = pattern.substring(1) } // Skip if the base version is not semver-compatible if (!semver.valid(baseVersion)) { return false } // Normalize tags by removing 'v' prefix if present const normalizeTag = (t: string): string => t.startsWith('v') ? t.substring(1) : t const normalizedTag = normalizeTag(tag) const normalizedBase = normalizeTag(baseVersion) // Skip if the tag is not semver-compatible if (!semver.valid(normalizedTag)) { return false } // Check if the tag satisfies the pattern if (isCaretPattern) { const parsed = semver.parse(normalizedBase) const range = parsed ? `>=${normalizedBase} <${parsed.major + 1}.0.0` : `>=${normalizedBase}` return semver.satisfies(normalizedTag, range) } else { // Tilde pattern const parsed = semver.parse(normalizedBase) const range = parsed ? `>=${normalizedBase} <${parsed.major}.${parsed.minor + 1}.0` : `>=${normalizedBase}` return semver.satisfies(normalizedTag, range) } } catch (error) { console.warn(`Error matching version pattern: ${error}`) return false } } /** * Check if a string is a version pattern * @param str String to check * @returns Whether the string is a version pattern */ function isVersionPattern(str: string): boolean { return ( str.includes('*') || str.includes('x') || str.startsWith('^') || str.startsWith('~') ) } /** * Compare two semver strings * @param a First version * @param b Second version * @returns Comparison result */ function semverCompare(a: string, b: string): number { return semver.compare(a, b) } /** * Checks if a name matches a pattern (e.g. "v1.0.0" matches "^1") * @param tag Tag to check * @param pattern Version pattern * @returns Whether the tag matches the pattern */ export function checkPatternMatch(tag: string, pattern: string): boolean { // If pattern is 'latest', it only matches the 'latest' tag if (pattern === 'latest') { return tag === 'latest' } // Handle exact matches if (pattern === tag) { return true } // Normalize tags by removing 'v' prefix if present for comparison const normalizeTag = (t: string): string => t.startsWith('v') ? t.substring(1) : t const normalizedPattern = normalizeTag(pattern) const normalizedTag = normalizeTag(tag) // Handle wildcards (e.g., 1.x, 1.*) if (normalizedPattern.includes('x') || normalizedPattern.includes('*')) { const patternParts = normalizedPattern.split('.') const tagParts = normalizedTag.split('.') // Check each part of the version for (let i = 0; i < patternParts.length; i++) { const patternPart = patternParts[i] // If this part is a wildcard, it matches anything if (patternPart === 'x' || patternPart === '*') { continue } // If tag doesn't have this part or it doesn't match, return false if (!tagParts[i] || tagParts[i] !== patternPart) { return false } } return true } // Handle caret (^) notation (e.g., ^1.2.3 matches 1.x.y where x >= 2 or x = 2 and y >= 3) if (normalizedPattern.startsWith('^')) { const versionWithoutCaret = normalizedPattern.substring(1) // Check if tag and pattern (without ^) are valid semver const parsedPattern = semver.parse(versionWithoutCaret) const parsedTag = semver.parse(normalizedTag) if (parsedPattern && parsedTag) { // Use semver.satisfies to correctly implement semver behavior // For ^0.y.z semver patterns, this will match 0.y.z or higher (within the 0.y range) return semver.satisfies(normalizedTag, `^${versionWithoutCaret}`) } else { // If semver parse fails, fall back to checking major version const patternParts = versionWithoutCaret.split('.') const tagParts = normalizedTag.split('.') // For non-standard versions, just check if major versions match return tagParts[0] === patternParts[0] } } // Handle tilde (~) notation (e.g., ~1.2.3 matches 1.2.x where x >= 3) if (normalizedPattern.startsWith('~')) { const versionWithoutTilde = normalizedPattern.substring(1) // Check if tag and pattern (without ~) are valid semver const parsedPattern = semver.parse(versionWithoutTilde) const parsedTag = semver.parse(normalizedTag) if (parsedPattern && parsedTag) { // Use semver.satisfies to correctly implement semver behavior return semver.satisfies(normalizedTag, `~${versionWithoutTilde}`) } else { // Fallback to simple prefix check if semver parse fails const patternParts = versionWithoutTilde.split('.') const tagParts = normalizedTag.split('.') // Check if major and minor versions match if (patternParts.length >= 2 && tagParts.length >= 2) { return ( tagParts[0] === patternParts[0] && tagParts[1] === patternParts[1] ) } else { return normalizedTag.startsWith(versionWithoutTilde) } } } // No match found return false }