import { execSync, spawn } from 'child_process' import { performance } from 'perf_hooks' import { findExistingLockEntry, updateLockEntry } from './lock' import { findHighestMatchingTag } from './versions' /** * Check if a tag is a pattern rather than an exact version * @param tag Tag to check * @returns Whether the tag is a pattern (contains *, x, ^, or ~) */ export function isVersionPattern(tag: string): boolean { return ( tag === 'latest' || tag.includes('*') || tag.includes('x') || tag.startsWith('^') || tag.startsWith('~') ) } /** * Resolve a version pattern to an actual tag * @param imageName Image name * @param versionPattern Version pattern to resolve * @param verbose Whether to log verbose output * @returns Actual tag */ export async function resolveVersionPattern( imageName: string, versionPattern: string, verbose = false ): Promise { // First, check if this is even a pattern if (!isVersionPattern(versionPattern)) { if (verbose) { console.log(`Using ${versionPattern} as the actual tag (not a pattern)`) } return versionPattern } if (verbose) { console.log(`Resolving version pattern...`) } try { // Get the highest matching tag const highestTag = await findHighestMatchingTag( imageName, versionPattern, verbose ) if (highestTag) { if (verbose) { console.log(`Resolved to tag: ${highestTag}`) } return highestTag } else { console.log( `No matching tag found, using ${versionPattern} as the actual tag for Docker operations` ) return versionPattern } } catch (error) { console.error(`Error resolving version pattern: ${error}`) // In case of error, fall back to the original pattern if (verbose) { console.log( `Using ${versionPattern} as the actual tag for Docker operations` ) } return versionPattern } } /** * Check if there's an update available for the given image * @param imageName The name of the image to check * @param configuredTag The configured tag pattern * @param verbose Whether to output verbose information * @returns Update check result */ export async function checkImageUpdate( imageName: string, configuredTag: string, verbose = false ): Promise { const startTime = performance.now() if (verbose) { console.log(`Checking for updates for ${imageName}:${configuredTag}...`) } try { // Get the resolved version - this will call getAvailableTags inside const resolvedTag = await resolveVersionPattern( imageName, configuredTag, verbose ) if (!resolvedTag) { return { hasUpdate: false, currentVersion: configuredTag, availableTags: [], found: false, } } // Get the current version (try both local and remote) let currentVersion = configuredTag // Check if the image exists locally - silently (don't output errors) const found = doesImageExist(imageName, resolvedTag) // Get all tags - we don't need to fetch them again, we can use what we already have // Just run a single command to check if we have the resolved tag locally let allTags: string[] = [] // Get the list of available tags - this comes from the registry check we already did try { // Use existing information from the image check const cmd = `docker images ${imageName} --format="{{.Tag}}"` const localOutput = execSync(cmd, { encoding: 'utf8' }).trim() const localTags = localOutput ? localOutput.split('\n').filter(Boolean) : [] if (verbose) { console.log(`Tags detected for ${imageName}`) } // Get locked digest if it exists let lockedDigest: string | null = null // Check if we have a locked version for this image let installedVersion = currentVersion const existingLock = findExistingLockEntry(imageName) if (existingLock) { installedVersion = existingLock.entry.tag if (verbose) { console.log(`Found installed version: ${installedVersion}`) } } try { // Try to get the digest of the configured tag, not the semver pattern // This avoids errors like "No such object: zondax/zzup-make:^0" // Only do this if it's not a semver pattern if (!isVersionPattern(installedVersion)) { const inspectCmd = `docker inspect --format="{{index .RepoDigests 0}}" ${imageName}:${installedVersion}` const inspectOutput = execSync(inspectCmd, { encoding: 'utf8', stdio: 'pipe', }).trim() // Extract just the sha256 part if (inspectOutput && inspectOutput.includes('@sha256:')) { lockedDigest = inspectOutput.split('@sha256:')[1] if (verbose) { console.log(`Current digest: sha256:${lockedDigest}`) } } } } catch (error) { if (verbose) { console.log(`No locked digest found`) } lockedDigest = null } // Compare versions - this is the key check for whether there's an update // Check if the resolved tag is newer than our installed version // We need semver comparison here to handle version patterns correctly let hasUpdate = false try { // Compare semver if possible const semver = require('semver') if (semver.valid(resolvedTag) && semver.valid(installedVersion)) { hasUpdate = semver.gt(resolvedTag, installedVersion) } else { // Fall back to string comparison if not valid semver hasUpdate = resolvedTag !== installedVersion && !found } } catch (error) { // If semver comparison fails, fall back to string comparison hasUpdate = resolvedTag !== installedVersion && !found } if (verbose) { console.log(`Installed version: ${installedVersion}`) console.log(`Available version: ${resolvedTag}`) console.log(`Has update: ${hasUpdate}`) } const endTime = performance.now() if (verbose) { console.log(`Update check took ${(endTime - startTime).toFixed(2)}ms`) } // Convert to string array for API compatibility allTags = [...localTags] // Add remote tags if we have them return { hasUpdate, currentVersion: installedVersion, availableVersion: resolvedTag, availableTags: allTags, lockedDigest, found, } } catch (error) { console.error(`Error checking for updates: ${error}`) return { hasUpdate: false, currentVersion: configuredTag, availableTags: [], found: false, } } } catch (error) { console.error(`Error checking for updates: ${error}`) return { hasUpdate: false, currentVersion: configuredTag, availableTags: [], found: false, } } } /** * Execute a command asynchronously * @param command Command to execute * @param args Command arguments * @param cwd Current working directory * @returns Promise with command output */ function execCommand( command: string, args: string[], cwd?: string ): Promise { return new Promise((resolve, reject) => { // Use the child_process.spawn method to run the command const child = spawn(command, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], }) let stdout = '' let stderr = '' child.stdout.on('data', data => { stdout += data.toString() }) child.stderr.on('data', data => { stderr += data.toString() }) child.on('close', code => { if (code === 0) { resolve(stdout) } else { reject(new Error(`Command failed with code ${code}: ${stderr}`)) } }) child.on('error', err => { reject(err) }) }) } /** * Update the lock file after installing an image * @param imageName Image name * @param tag Tag */ export async function updateLockFileFromImage( imageName: string, tag: string ): Promise { try { const output = await execCommand('docker', [ 'inspect', `${imageName}:${tag}`, ]) const inspectData = JSON.parse(output) if (inspectData && inspectData.length > 0) { const digest = inspectData[0].Id || '' // Update lock file updateLockEntry(imageName, tag, digest) console.log(`Updated lock file with digest: ${digest}`) } } catch (error) { console.warn(`Could not update lock file: ${error}`) } } /** * Checks if a Docker image with the given name and tag exists locally * @param imageName The name of the image * @param tag The tag to check * @returns True if the image exists locally, false otherwise */ function doesImageExist(imageName: string, tag: string): boolean { try { const command = `docker image inspect ${imageName}:${tag}` execSync(command, { encoding: 'utf8', stdio: 'ignore' }) return true } catch (error) { // If the command fails, the image doesn't exist // Don't log the error to avoid confusion return false } } /** * Check if an image needs updating based on lock file and remote digest * @param imageName Image name * @param tag Tag * @returns Object containing needsUpdate flag and other details */ export interface UpdateCheckResult { hasUpdate: boolean currentVersion: string availableVersion?: string availableTags: string[] // Keep as string[] for API compatibility lockedDigest?: string | null found: boolean }