import { quote } from 'shlex'; import upath from 'upath'; import { logger } from '../../../logger/index.ts'; import { ExecError } from '../../../util/exec/exec-error.ts'; import type { ExecOptions } from '../../../util/exec/types.ts'; import { localPathIsSymbolicLink, readLocalSymlink, } from '../../../util/fs/index.ts'; import { withGitEnvironment } from '../../../util/git/exec.ts'; import { getRepoStatus } from '../../../util/git/index.ts'; import * as p from '../../../util/promises.ts'; import type { UpdateArtifact, UpdateArtifactsResult } from '../types.ts'; import type { ReadContentResult } from './types.ts'; const gitExec = withGitEnvironment(['hermit']); /** * updateArtifacts runs hermit install for each updated dependencies */ export async function updateArtifacts( update: UpdateArtifact, ): Promise { const { packageFileName } = update; try { await updateHermitPackage(update); } catch (err) { logger.debug({ err }, `error updating hermit packages.`); if (err instanceof UpdateHermitError) { return [ { artifactError: { fileName: `from: ${err.from}, to: ${err.to}`, stderr: err.stderr, }, }, ]; } return [ { artifactError: { stderr: err instanceof Error ? err.message : String(err), }, }, ]; } logger.debug(`scanning the changes after update`); let updateResult: UpdateArtifactsResult[] | null = null; try { updateResult = await getUpdateResult(packageFileName); logger.debug({ updateResult }, `update result for hermit`); } catch (err) { logger.debug({ err }, 'Error getting hermet update results'); return [ { artifactError: { stderr: err.message, }, }, ]; } return updateResult; } /** * getContent returns the content of either link or a normal file */ async function getContent(file: string): Promise { let contents: string | null = ''; const isSymlink = await localPathIsSymbolicLink(file); if (isSymlink) { contents = await readLocalSymlink(file); } if (contents === null) { throw new Error(`error getting content for ${file}`); } return { isSymlink, contents, }; } /** * getAddResult returns the UpdateArtifactsResult for the added files */ function getAddResult( path: string, contentRes: ReadContentResult, ): UpdateArtifactsResult { return { file: { type: 'addition', path, contents: contentRes.contents, isSymlink: contentRes.isSymlink, isExecutable: contentRes.isExecutable, }, }; } /** * getDeleteResult returns the UpdateArtifactsResult for deleted files */ function getDeleteResult(path: string): UpdateArtifactsResult { return { file: { type: 'deletion', path, }, }; } /** * getUpdateResult will return the update result after `hermit install` * has been performed for all packages */ async function getUpdateResult( packageFileName: string, ): Promise { const hermitFolder = `${upath.dirname(packageFileName)}/`; const hermitChanges = await getRepoStatus(hermitFolder); logger.debug( { hermitChanges, hermitFolder }, `hermit changes after package update`, ); // handle added files const added = await p.map( [...hermitChanges.created, ...hermitChanges.not_added], async (path: string): Promise => { const contents = await getContent(path); return getAddResult(path, contents); }, ); const deleted = hermitChanges.deleted.map(getDeleteResult); const modified = await p.map( hermitChanges.modified, async (path: string): Promise => { const contents = await getContent(path); return [ getDeleteResult(path), // delete existing link getAddResult(path, contents), // add a new link ]; }, ); const renamed = await p.map( hermitChanges.renamed, async (renamed): Promise => { const from = renamed.from; const to = renamed.to; const toContents = await getContent(to); return [getDeleteResult(from), getAddResult(to, toContents)]; }, ); return [ // rename will need to go first, because // it needs to create the new link for the new version // for the modified links to use ...renamed.flat(), ...modified.flat(), ...added, ...deleted, ]; } /** * getHermitPackage returns the hermit package for running the hermit install */ function getHermitPackage(name: string, version: string): string { return `${name}-${version}`; } /** * updateHermitPackage runs hermit install for the given package */ async function updateHermitPackage(update: UpdateArtifact): Promise { logger.trace({ update }, `hermit.updateHermitPackage()`); const toInstall = []; const from = []; // storing the old package for replacement const toUninstall = []; for (const pkg of update.updatedDeps) { if (!pkg.depName || !pkg.currentVersion || !pkg.newValue) { logger.debug( { depName: pkg.depName, currentVersion: pkg.currentVersion, newValue: pkg.newValue, }, 'missing package update information', ); throw new UpdateHermitError( getHermitPackage(pkg.depName ?? '', pkg.currentVersion ?? ''), getHermitPackage(pkg.depName ?? '', pkg.newValue ?? ''), 'invalid package to update', ); } const depName = pkg.depName; const newName = pkg.newName; const currentVersion = pkg.currentVersion; const newValue = pkg.newValue; const fromPackage = getHermitPackage(depName, currentVersion); // newName will be available for replacement const toPackage = getHermitPackage(newName ?? depName, newValue); toInstall.push(toPackage); from.push(fromPackage); // skips uninstall for version only replacement if (pkg.updateType === 'replacement' && newName !== depName) { toUninstall.push(depName); } } const execOptions: ExecOptions = { docker: {}, cwdFile: update.packageFileName, }; const fromPackages = from.map(quote).join(' '); // when a name replacement happens, need to uninstall the old package if (toUninstall.length > 0) { const packagesToUninstall = toUninstall.map(quote).join(' '); const uninstallCommands = `./hermit uninstall ${packagesToUninstall}`; try { const result = await gitExec(uninstallCommands, execOptions); logger.trace( { stdout: result.stdout }, `hermit uninstall command stdout`, ); } catch (e) { logger.warn({ err: e }, `error uninstall hermit package for replacement`); if (!(e instanceof ExecError)) { throw e; } throw new UpdateHermitError( fromPackages, packagesToUninstall, e.stderr, e.stdout, ); } } const packagesToInstall = toInstall.map(quote).join(' '); const execCommands = `./hermit install ${packagesToInstall}`; logger.debug( { packageFile: update.packageFileName, packagesToInstall, }, `performing updates`, ); try { const result = await gitExec(execCommands, execOptions); logger.trace({ stdout: result.stdout }, `hermit command stdout`); } catch (e) { logger.warn({ err: e }, `error updating hermit package`); if (!(e instanceof ExecError)) { throw e; } throw new UpdateHermitError( fromPackages, packagesToInstall, e.stderr, e.stdout, ); } } export class UpdateHermitError extends Error { stdout: string; stderr: string; from: string; to: string; constructor(from: string, to: string, stderr: string, stdout = '') { super(); this.stdout = stdout; this.stderr = stderr; this.from = from; this.to = to; } }