import * as core from '@actions/core' import * as io from '@actions/io' import * as fs from 'fs' import * as os from 'os' import * as path from 'path' import * as httpm from 'typed-rest-client/HttpClient' import * as semver from 'semver' import * as uuidV4 from 'uuid/v4' import {exec} from '@actions/exec/lib/exec' import {ExecOptions} from '@actions/exec/lib/interfaces' import {ok} from 'assert' export class HTTPError extends Error { constructor(readonly httpStatusCode: number | undefined) { super(`Unexpected HTTP response: ${httpStatusCode}`) Object.setPrototypeOf(this, new.target.prototype) } } const IS_WINDOWS = process.platform === 'win32' const userAgent = 'actions/tool-cache' // On load grab temp directory and cache directory and remove them from env (currently don't want to expose this) let tempDirectory: string = process.env['RUNNER_TEMPDIRECTORY'] || '' let cacheRoot: string = process.env['RUNNER_TOOLSDIRECTORY'] || '' // If directories not found, place them in common temp locations if (!tempDirectory || !cacheRoot) { let baseLocation: string if (IS_WINDOWS) { // On windows use the USERPROFILE env variable baseLocation = process.env['USERPROFILE'] || 'C:\\' } else { if (process.platform === 'darwin') { baseLocation = '/Users' } else { baseLocation = '/home' } } if (!tempDirectory) { tempDirectory = path.join(baseLocation, 'actions', 'temp') } if (!cacheRoot) { cacheRoot = path.join(baseLocation, 'actions', 'cache') } } /** * Download a tool from an url and stream it into a file * * @param url url of tool to download * @returns path to downloaded tool */ export async function downloadTool(url: string): Promise { // Wrap in a promise so that we can resolve from within stream callbacks return new Promise(async (resolve, reject) => { try { const http = new httpm.HttpClient(userAgent, [], { allowRetries: true, maxRetries: 3 }) const destPath = path.join(tempDirectory, uuidV4()) await io.mkdirP(tempDirectory) core.debug(`Downloading ${url}`) core.debug(`Downloading ${destPath}`) if (fs.existsSync(destPath)) { throw new Error(`Destination file path ${destPath} already exists`) } const response: httpm.HttpClientResponse = await http.get(url) if (response.message.statusCode !== 200) { const err = new HTTPError(response.message.statusCode) core.debug( `Failed to download from "${url}". Code(${ response.message.statusCode }) Message(${response.message.statusMessage})` ) throw err } const file: NodeJS.WritableStream = fs.createWriteStream(destPath) file.on('open', async () => { try { const stream = response.message.pipe(file) stream.on('close', () => { core.debug('download complete') resolve(destPath) }) } catch (err) { core.debug( `Failed to download from "${url}". Code(${ response.message.statusCode }) Message(${response.message.statusMessage})` ) reject(err) } }) file.on('error', err => { file.end() reject(err) }) } catch (err) { reject(err) } }) } /** * Extract a .7z file * * @param file path to the .7z file * @param dest destination directory. Optional. * @param _7zPath path to 7zr.exe. Optional, for long path support. Most .7z archives do not have this * problem. If your .7z archive contains very long paths, you can pass the path to 7zr.exe which will * gracefully handle long paths. By default 7zdec.exe is used because it is a very small program and is * bundled with the tool lib. However it does not support long paths. 7zr.exe is the reduced command line * interface, it is smaller than the full command line interface, and it does support long paths. At the * time of this writing, it is freely available from the LZMA SDK that is available on the 7zip website. * Be sure to check the current license agreement. If 7zr.exe is bundled with your action, then the path * to 7zr.exe can be pass to this function. * @returns path to the destination directory */ export async function extract7z( file: string, dest?: string, _7zPath?: string ): Promise { ok(IS_WINDOWS, 'extract7z() not supported on current OS') ok(file, 'parameter "file" is required') dest = dest || (await _createExtractFolder(dest)) const originalCwd = process.cwd() process.chdir(dest) if (_7zPath) { try { const args: string[] = [ 'x', // eXtract files with full paths '-bb1', // -bb[0-3] : set output log level '-bd', // disable progress indicator '-sccUTF-8', // set charset for for console input/output file ] const options: ExecOptions = { silent: true } await exec(`"${_7zPath}"`, args, options) } finally { process.chdir(originalCwd) } } else { const escapedScript = path .join(__dirname, '..', 'scripts', 'Invoke-7zdec.ps1') .replace(/'/g, "''") .replace(/"|\n|\r/g, '') // double-up single quotes, remove double quotes and newlines const escapedFile = file.replace(/'/g, "''").replace(/"|\n|\r/g, '') const escapedTarget = dest.replace(/'/g, "''").replace(/"|\n|\r/g, '') const command = `& '${escapedScript}' -Source '${escapedFile}' -Target '${escapedTarget}'` const args: string[] = [ '-NoLogo', '-Sta', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Unrestricted', '-Command', command ] const options: ExecOptions = { silent: true } try { const powershellPath: string = await io.which('powershell', true) await exec(`"${powershellPath}"`, args, options) } finally { process.chdir(originalCwd) } } return dest } /** * Extract a tar * * @param file path to the tar * @param dest destination directory. Optional. * @returns path to the destination directory */ export async function extractTar(file: string, dest?: string): Promise { if (!file) { throw new Error("parameter 'file' is required") } dest = dest || (await _createExtractFolder(dest)) const tarPath: string = await io.which('tar', true) await exec(`"${tarPath}"`, ['xzC', dest, '-f', file]) return dest } /** * Extract a zip * * @param file path to the zip * @param dest destination directory. Optional. * @returns path to the destination directory */ export async function extractZip(file: string, dest?: string): Promise { if (!file) { throw new Error("parameter 'file' is required") } dest = dest || (await _createExtractFolder(dest)) if (IS_WINDOWS) { await extractZipWin(file, dest) } else { await extractZipNix(file, dest) } return dest } async function extractZipWin(file: string, dest: string): Promise { // build the powershell command const escapedFile = file.replace(/'/g, "''").replace(/"|\n|\r/g, '') // double-up single quotes, remove double quotes and newlines const escapedDest = dest.replace(/'/g, "''").replace(/"|\n|\r/g, '') const command = `$ErrorActionPreference = 'Stop' ; try { Add-Type -AssemblyName System.IO.Compression.FileSystem } catch { } ; [System.IO.Compression.ZipFile]::ExtractToDirectory('${escapedFile}', '${escapedDest}')` // run powershell const powershellPath = await io.which('powershell') const args = [ '-NoLogo', '-Sta', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Unrestricted', '-Command', command ] await exec(`"${powershellPath}"`, args) } async function extractZipNix(file: string, dest: string): Promise { const unzipPath = path.join(__dirname, '..', 'scripts', 'externals', 'unzip') await exec(`"${unzipPath}"`, [file], {cwd: dest}) } /** * Caches a directory and installs it into the tool cacheDir * * @param sourceDir the directory to cache into tools * @param tool tool name * @param version version of the tool. semver format * @param arch architecture of the tool. Optional. Defaults to machine architecture */ export async function cacheDir( sourceDir: string, tool: string, version: string, arch?: string ): Promise { version = semver.clean(version) || version arch = arch || os.arch() core.debug(`Caching tool ${tool} ${version} ${arch}`) core.debug(`source dir: ${sourceDir}`) if (!fs.statSync(sourceDir).isDirectory()) { throw new Error('sourceDir is not a directory') } // Create the tool dir const destPath: string = await _createToolPath(tool, version, arch) // copy each child item. do not move. move can fail on Windows // due to anti-virus software having an open handle on a file. for (const itemName of fs.readdirSync(sourceDir)) { const s = path.join(sourceDir, itemName) await io.cp(s, destPath, {recursive: true}) } // write .complete _completeToolPath(tool, version, arch) return destPath } /** * Caches a downloaded file (GUID) and installs it * into the tool cache with a given targetName * * @param sourceFile the file to cache into tools. Typically a result of downloadTool which is a guid. * @param targetFile the name of the file name in the tools directory * @param tool tool name * @param version version of the tool. semver format * @param arch architecture of the tool. Optional. Defaults to machine architecture */ export async function cacheFile( sourceFile: string, targetFile: string, tool: string, version: string, arch?: string ): Promise { version = semver.clean(version) || version arch = arch || os.arch() core.debug(`Caching tool ${tool} ${version} ${arch}`) core.debug(`source file: ${sourceFile}`) if (!fs.statSync(sourceFile).isFile()) { throw new Error('sourceFile is not a file') } // create the tool dir const destFolder: string = await _createToolPath(tool, version, arch) // copy instead of move. move can fail on Windows due to // anti-virus software having an open handle on a file. const destPath: string = path.join(destFolder, targetFile) core.debug(`destination file ${destPath}`) await io.cp(sourceFile, destPath) // write .complete _completeToolPath(tool, version, arch) return destFolder } /** * finds the path to a tool in the local installed tool cache * * @param toolName name of the tool * @param versionSpec version of the tool * @param arch optional arch. defaults to arch of computer */ export function find( toolName: string, versionSpec: string, arch?: string ): string { if (!toolName) { throw new Error('toolName parameter is required') } if (!versionSpec) { throw new Error('versionSpec parameter is required') } arch = arch || os.arch() // attempt to resolve an explicit version if (!_isExplicitVersion(versionSpec)) { const localVersions: string[] = _findLocalToolVersions(toolName, arch) const match = _evaluateVersions(localVersions, versionSpec) versionSpec = match } // check for the explicit version in the cache let toolPath = '' if (versionSpec) { versionSpec = semver.clean(versionSpec) || '' const cachePath = path.join(cacheRoot, toolName, versionSpec, arch) core.debug(`checking cache: ${cachePath}`) if (fs.existsSync(cachePath) && fs.existsSync(`${cachePath}.complete`)) { core.debug(`Found tool in cache ${toolName} ${versionSpec} ${arch}`) toolPath = cachePath } else { core.debug('not found') } } return toolPath } async function _createExtractFolder(dest?: string): Promise { if (!dest) { // create a temp dir dest = path.join(tempDirectory, uuidV4()) } await io.mkdirP(dest) return dest } async function _createToolPath( tool: string, version: string, arch?: string ): Promise { const folderPath = path.join( cacheRoot, tool, semver.clean(version) || version, arch || '' ) core.debug(`destination ${folderPath}`) const markerPath = `${folderPath}.complete` await io.rmRF(folderPath) await io.rmRF(markerPath) await io.mkdirP(folderPath) return folderPath } function _completeToolPath(tool: string, version: string, arch?: string): void { const folderPath = path.join( cacheRoot, tool, semver.clean(version) || version, arch || '' ) const markerPath = `${folderPath}.complete` fs.writeFileSync(markerPath, '') core.debug('finished caching tool') } function _isExplicitVersion(versionSpec: string): boolean { const c = semver.clean(versionSpec) || '' core.debug(`isExplicit: ${c}`) const valid = semver.valid(c) != null core.debug(`explicit? ${valid}`) return valid } function _evaluateVersions(versions: string[], versionSpec: string): string { let version = '' core.debug(`evaluating ${versions.length} versions`) versions = versions.sort((a, b) => { if (semver.gt(a, b)) { return 1 } return -1 }) for (let i = versions.length - 1; i >= 0; i--) { const potential: string = versions[i] const satisfied: boolean = semver.satisfies(potential, versionSpec) if (satisfied) { version = potential break } } if (version) { core.debug(`matched: ${version}`) } else { core.debug('match not found') } return version } function _findLocalToolVersions(toolName: string, arch?: string): string[] { const versions: string[] = [] arch = arch || os.arch() const toolPath = path.join(cacheRoot, toolName) if (fs.existsSync(toolPath)) { const children: string[] = fs.readdirSync(toolPath) for (const child of children) { if (_isExplicitVersion(child)) { const fullPath = path.join(toolPath, child, arch || '') if (fs.existsSync(fullPath) && fs.existsSync(`${fullPath}.complete`)) { versions.push(child) } } } } return versions }