mirror of https://github.com/actions/toolkit
Add io (#5)
* Add io lib * io cleanup * Run format script * Fix lint errors with autofix * Fix equality lint errors * Rename ioUtil to io-util * Add no-import-requires * Run auto-fix lint * Remove lint errors * Use Boolean() to convert options - `CopyOptions` on `cp` now defaults to empty - Setting option values is easier now * Rewrite packages/io to be fully async * Move IS_WINDOWS into ioUtil * DRY up cp/mv by moving shared code into move function * Remove unc support, change isDirectory call to stat * Tighter try catches * more concise extensions search * Allow isDirectory to be stat or lstat * format * Shell out to rm -rf * Remove unc comment * Export fs.promises from io-util * Remove unknown error message * Create an optimistic mkdirp * Update io-util.ts * Update io-util.ts * Update io.test.ts * Fix tests for mkdirPpull/11/head
parent
4f5f4f2fb8
commit
08db5110c6
|
@ -26,8 +26,6 @@
|
|||
"@typescript-eslint/no-array-constructor": "error",
|
||||
"@typescript-eslint/no-empty-interface": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"no-extra-parens": "off",
|
||||
"@typescript-eslint/no-extra-parens": "error",
|
||||
"@typescript-eslint/no-extraneous-class": "error",
|
||||
"@typescript-eslint/no-for-in-array": "error",
|
||||
"@typescript-eslint/no-inferrable-types": "error",
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
node_modules/
|
||||
packages/*/node_modules/
|
||||
packages/*/lib/
|
||||
packages/*/lib/
|
||||
packages/*/__tests__/_temp/
|
|
@ -0,0 +1,49 @@
|
|||
# `@actions/io`
|
||||
|
||||
> Core functions for cli filesystem scenarios
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/**
|
||||
* Copies a file or folder.
|
||||
*
|
||||
* @param source source path
|
||||
* @param dest destination path
|
||||
* @param options optional. See CopyOptions.
|
||||
*/
|
||||
export function cp(source: string, dest: string, options?: CopyOptions): Promise<void>
|
||||
|
||||
/**
|
||||
* Remove a path recursively with force
|
||||
*
|
||||
* @param path path to remove
|
||||
*/
|
||||
export function rmRF(path: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Make a directory. Creates the full path with folders in between
|
||||
*
|
||||
* @param p path to create
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export function mkdirP(p: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Moves a path.
|
||||
*
|
||||
* @param source source path
|
||||
* @param dest destination path
|
||||
* @param options optional. See CopyOptions.
|
||||
*/
|
||||
export function mv(source: string, dest: string, options?: CopyOptions): Promise<void>
|
||||
|
||||
/**
|
||||
* Returns path of a tool had the tool actually been invoked. Resolves via paths.
|
||||
*
|
||||
* @param tool name of the tool
|
||||
* @param options optional. See WhichOptions.
|
||||
* @returns Promise<string> path to tool
|
||||
*/
|
||||
export function which(tool: string, options?: WhichOptions): Promise<string>
|
||||
```
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@actions/io",
|
||||
"version": "1.0.0",
|
||||
"description": "Actions io lib",
|
||||
"keywords": [
|
||||
"io",
|
||||
"actions"
|
||||
],
|
||||
"author": "Danny McCormick <damccorm@microsoft.com>",
|
||||
"homepage": "https://github.com/actions/toolkit/tree/master/packages/io",
|
||||
"license": "MIT",
|
||||
"main": "lib/io.js",
|
||||
"directories": {
|
||||
"lib": "lib",
|
||||
"test": "__tests__"
|
||||
},
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/actions/toolkit.git"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "echo \"Error: run tests from root\" && exit 1",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/actions/toolkit/issues"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,211 @@
|
|||
import {ok} from 'assert'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
export const {
|
||||
copyFile,
|
||||
lstat,
|
||||
mkdir,
|
||||
readdir,
|
||||
rmdir,
|
||||
stat,
|
||||
unlink
|
||||
} = fs.promises
|
||||
|
||||
export const IS_WINDOWS = process.platform === 'win32'
|
||||
|
||||
export async function exists(fsPath: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(fsPath)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
return false
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export async function isDirectory(
|
||||
fsPath: string,
|
||||
useStat: boolean = false
|
||||
): Promise<boolean> {
|
||||
const stats = useStat ? await stat(fsPath) : await lstat(fsPath)
|
||||
return stats.isDirectory()
|
||||
}
|
||||
|
||||
/**
|
||||
* On OSX/Linux, true if path starts with '/'. On Windows, true for paths like:
|
||||
* \, \hello, \\hello\share, C:, and C:\hello (and corresponding alternate separator cases).
|
||||
*/
|
||||
export function isRooted(p: string): boolean {
|
||||
p = normalizeSeparators(p)
|
||||
if (!p) {
|
||||
throw new Error('isRooted() parameter "p" cannot be empty')
|
||||
}
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
return (
|
||||
p.startsWith('\\') || /^[A-Z]:/i.test(p) // e.g. \ or \hello or \\hello
|
||||
) // e.g. C: or C:\hello
|
||||
}
|
||||
|
||||
return p.startsWith('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively create a directory at `fsPath`.
|
||||
*
|
||||
* This implementation is optimistic, meaning it attempts to create the full
|
||||
* path first, and backs up the path stack from there.
|
||||
*
|
||||
* @param fsPath The path to create
|
||||
* @param maxDepth The maximum recursion depth
|
||||
* @param depth The current recursion depth
|
||||
*/
|
||||
export async function mkdirP(
|
||||
fsPath: string,
|
||||
maxDepth: number = 1000,
|
||||
depth: number = 1
|
||||
): Promise<void> {
|
||||
ok(fsPath, 'a path argument must be provided')
|
||||
|
||||
fsPath = path.resolve(fsPath)
|
||||
|
||||
if (depth >= maxDepth) return mkdir(fsPath)
|
||||
|
||||
try {
|
||||
await mkdir(fsPath)
|
||||
return
|
||||
} catch (err) {
|
||||
switch (err.code) {
|
||||
case 'ENOENT': {
|
||||
await mkdirP(path.dirname(fsPath), maxDepth, depth + 1)
|
||||
await mkdir(fsPath)
|
||||
return
|
||||
}
|
||||
default: {
|
||||
let stats: fs.Stats
|
||||
|
||||
try {
|
||||
stats = await stat(fsPath)
|
||||
} catch (err2) {
|
||||
throw err
|
||||
}
|
||||
|
||||
if (!stats.isDirectory()) throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best effort attempt to determine whether a file exists and is executable.
|
||||
* @param filePath file path to check
|
||||
* @param extensions additional file extensions to try
|
||||
* @return if file exists and is executable, returns the file path. otherwise empty string.
|
||||
*/
|
||||
export async function tryGetExecutablePath(
|
||||
filePath: string,
|
||||
extensions: string[]
|
||||
): Promise<string> {
|
||||
let stats: fs.Stats | undefined = undefined
|
||||
try {
|
||||
// test file exists
|
||||
stats = await stat(filePath)
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Unexpected error attempting to determine if executable file exists '${filePath}': ${err}`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (stats && stats.isFile()) {
|
||||
if (IS_WINDOWS) {
|
||||
// on Windows, test for valid extension
|
||||
const upperExt = path.extname(filePath).toUpperCase()
|
||||
if (extensions.some(validExt => validExt.toUpperCase() === upperExt)) {
|
||||
return filePath
|
||||
}
|
||||
} else {
|
||||
if (isUnixExecutable(stats)) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// try each extension
|
||||
const originalFilePath = filePath
|
||||
for (const extension of extensions) {
|
||||
filePath = originalFilePath + extension
|
||||
|
||||
stats = undefined
|
||||
try {
|
||||
stats = await stat(filePath)
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Unexpected error attempting to determine if executable file exists '${filePath}': ${err}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (stats && stats.isFile()) {
|
||||
if (IS_WINDOWS) {
|
||||
// preserve the case of the actual file (since an extension was appended)
|
||||
try {
|
||||
const directory = path.dirname(filePath)
|
||||
const upperName = path.basename(filePath).toUpperCase()
|
||||
for (const actualName of await readdir(directory)) {
|
||||
if (upperName === actualName.toUpperCase()) {
|
||||
filePath = path.join(directory, actualName)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Unexpected error attempting to determine the actual case of the file '${filePath}': ${err}`
|
||||
)
|
||||
}
|
||||
|
||||
return filePath
|
||||
} else {
|
||||
if (isUnixExecutable(stats)) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeSeparators(p: string): string {
|
||||
p = p || ''
|
||||
if (IS_WINDOWS) {
|
||||
// convert slashes on Windows
|
||||
p = p.replace(/\//g, '\\')
|
||||
|
||||
// remove redundant slashes
|
||||
return p.replace(/\\\\+/g, '\\')
|
||||
}
|
||||
|
||||
// remove redundant slashes
|
||||
return p.replace(/\/\/+/g, '/')
|
||||
}
|
||||
|
||||
// on Mac/Linux, test the execute bit
|
||||
// R W X R W X R W X
|
||||
// 256 128 64 32 16 8 4 2 1
|
||||
function isUnixExecutable(stats: fs.Stats): boolean {
|
||||
return (
|
||||
(stats.mode & 1) > 0 ||
|
||||
((stats.mode & 8) > 0 && stats.gid === process.getgid()) ||
|
||||
((stats.mode & 64) > 0 && stats.uid === process.getuid())
|
||||
)
|
||||
}
|
|
@ -0,0 +1,286 @@
|
|||
import * as childProcess from 'child_process'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import {promisify} from 'util'
|
||||
import * as ioUtil from './io-util'
|
||||
|
||||
const exec = promisify(childProcess.exec)
|
||||
|
||||
/**
|
||||
* Interface for cp/mv options
|
||||
*/
|
||||
export interface CopyOptions {
|
||||
/** Optional. Whether to recursively copy all subdirectories. Defaults to false */
|
||||
recursive?: boolean
|
||||
/** Optional. Whether to overwrite existing files in the destination. Defaults to true */
|
||||
force?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a file or folder.
|
||||
*
|
||||
* @param source source path
|
||||
* @param dest destination path
|
||||
* @param options optional. See CopyOptions.
|
||||
*/
|
||||
export async function cp(
|
||||
source: string,
|
||||
dest: string,
|
||||
options: CopyOptions = {}
|
||||
): Promise<void> {
|
||||
await move(source, dest, options, {deleteOriginal: false})
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a path.
|
||||
*
|
||||
* @param source source path
|
||||
* @param dest destination path
|
||||
* @param options optional. See CopyOptions.
|
||||
*/
|
||||
export async function mv(
|
||||
source: string,
|
||||
dest: string,
|
||||
options: CopyOptions = {}
|
||||
): Promise<void> {
|
||||
await move(source, dest, options, {deleteOriginal: true})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a path recursively with force
|
||||
*
|
||||
* @param inputPath path to remove
|
||||
*/
|
||||
export async function rmRF(inputPath: string): Promise<void> {
|
||||
if (ioUtil.IS_WINDOWS) {
|
||||
// Node doesn't provide a delete operation, only an unlink function. This means that if the file is being used by another
|
||||
// program (e.g. antivirus), it won't be deleted. To address this, we shell out the work to rd/del.
|
||||
try {
|
||||
if (await ioUtil.isDirectory(inputPath, true)) {
|
||||
await exec(`rd /s /q "${inputPath}"`)
|
||||
} else {
|
||||
await exec(`del /f /a "${inputPath}"`)
|
||||
}
|
||||
} catch (err) {
|
||||
// if you try to delete a file that doesn't exist, desired result is achieved
|
||||
// other errors are valid
|
||||
if (err.code !== 'ENOENT') throw err
|
||||
}
|
||||
|
||||
// Shelling out fails to remove a symlink folder with missing source, this unlink catches that
|
||||
try {
|
||||
await ioUtil.unlink(inputPath)
|
||||
} catch (err) {
|
||||
// if you try to delete a file that doesn't exist, desired result is achieved
|
||||
// other errors are valid
|
||||
if (err.code !== 'ENOENT') throw err
|
||||
}
|
||||
} else {
|
||||
let isDir = false
|
||||
try {
|
||||
isDir = await ioUtil.isDirectory(inputPath)
|
||||
} catch (err) {
|
||||
// if you try to delete a file that doesn't exist, desired result is achieved
|
||||
// other errors are valid
|
||||
if (err.code !== 'ENOENT') throw err
|
||||
return
|
||||
}
|
||||
|
||||
if (isDir) {
|
||||
await exec(`rm -rf "${inputPath}"`)
|
||||
} else {
|
||||
await ioUtil.unlink(inputPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a directory. Creates the full path with folders in between
|
||||
* Will throw if it fails
|
||||
*
|
||||
* @param fsPath path to create
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export async function mkdirP(fsPath: string): Promise<void> {
|
||||
await ioUtil.mkdirP(fsPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns path of a tool had the tool actually been invoked. Resolves via paths.
|
||||
* If you check and the tool does not exist, it will throw.
|
||||
*
|
||||
* @param tool name of the tool
|
||||
* @param check whether to check if tool exists
|
||||
* @returns Promise<string> path to tool
|
||||
*/
|
||||
export async function which(tool: string, check?: boolean): Promise<string> {
|
||||
if (!tool) {
|
||||
throw new Error("parameter 'tool' is required")
|
||||
}
|
||||
|
||||
// recursive when check=true
|
||||
if (check) {
|
||||
const result: string = await which(tool, false)
|
||||
|
||||
if (!result) {
|
||||
if (ioUtil.IS_WINDOWS) {
|
||||
throw new Error(
|
||||
`Unable to locate executable file: ${tool}. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also verify the file has a valid extension for an executable file.`
|
||||
)
|
||||
} else {
|
||||
throw new Error(
|
||||
`Unable to locate executable file: ${tool}. Please verify either the file path exists or the file can be found within a directory specified by the PATH environment variable. Also check the file mode to verify the file is executable.`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// build the list of extensions to try
|
||||
const extensions: string[] = []
|
||||
if (ioUtil.IS_WINDOWS && process.env.PATHEXT) {
|
||||
for (const extension of process.env.PATHEXT.split(path.delimiter)) {
|
||||
if (extension) {
|
||||
extensions.push(extension)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if it's rooted, return it if exists. otherwise return empty.
|
||||
if (ioUtil.isRooted(tool)) {
|
||||
const filePath: string = await ioUtil.tryGetExecutablePath(
|
||||
tool,
|
||||
extensions
|
||||
)
|
||||
|
||||
if (filePath) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// if any path separators, return empty
|
||||
if (tool.includes('/') || (ioUtil.IS_WINDOWS && tool.includes('\\'))) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// build the list of directories
|
||||
//
|
||||
// Note, technically "where" checks the current directory on Windows. From a task lib perspective,
|
||||
// it feels like we should not do this. Checking the current directory seems like more of a use
|
||||
// case of a shell, and the which() function exposed by the task lib should strive for consistency
|
||||
// across platforms.
|
||||
const directories: string[] = []
|
||||
|
||||
if (process.env.PATH) {
|
||||
for (const p of process.env.PATH.split(path.delimiter)) {
|
||||
if (p) {
|
||||
directories.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// return the first match
|
||||
for (const directory of directories) {
|
||||
const filePath = await ioUtil.tryGetExecutablePath(
|
||||
directory + path.sep + tool,
|
||||
extensions
|
||||
)
|
||||
if (filePath) {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
} catch (err) {
|
||||
throw new Error(`which failed with message ${err.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Copies contents of source into dest, making any necessary folders along the way.
|
||||
// Deletes the original copy if deleteOriginal is true
|
||||
async function copyDirectoryContents(
|
||||
source: string,
|
||||
dest: string,
|
||||
force: boolean,
|
||||
deleteOriginal = false
|
||||
): Promise<void> {
|
||||
if (await ioUtil.isDirectory(source)) {
|
||||
if (await ioUtil.exists(dest)) {
|
||||
if (!(await ioUtil.isDirectory(dest))) {
|
||||
throw new Error(`${dest} is not a directory`)
|
||||
}
|
||||
} else {
|
||||
await mkdirP(dest)
|
||||
}
|
||||
|
||||
// Copy all child files, and directories recursively
|
||||
const sourceChildren: string[] = await ioUtil.readdir(source)
|
||||
|
||||
for (const newSource of sourceChildren) {
|
||||
const newDest = path.join(dest, path.basename(newSource))
|
||||
await copyDirectoryContents(
|
||||
path.resolve(source, newSource),
|
||||
newDest,
|
||||
force,
|
||||
deleteOriginal
|
||||
)
|
||||
}
|
||||
|
||||
if (deleteOriginal) {
|
||||
await ioUtil.rmdir(source)
|
||||
}
|
||||
} else {
|
||||
if (force) {
|
||||
await ioUtil.copyFile(source, dest)
|
||||
} else {
|
||||
await ioUtil.copyFile(source, dest, fs.constants.COPYFILE_EXCL)
|
||||
}
|
||||
if (deleteOriginal) {
|
||||
await ioUtil.unlink(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function move(
|
||||
source: string,
|
||||
dest: string,
|
||||
options: CopyOptions = {},
|
||||
moveOptions: {deleteOriginal: boolean}
|
||||
): Promise<void> {
|
||||
const {force, recursive} = readCopyOptions(options)
|
||||
|
||||
if (await ioUtil.isDirectory(source)) {
|
||||
if (!recursive) {
|
||||
throw new Error(`non-recursive cp failed, ${source} is a directory`)
|
||||
}
|
||||
|
||||
// If directory exists, move source inside it. Otherwise, create it and move contents of source inside.
|
||||
if (await ioUtil.exists(dest)) {
|
||||
if (!(await ioUtil.isDirectory(dest))) {
|
||||
throw new Error(`${dest} is not a directory`)
|
||||
}
|
||||
|
||||
dest = path.join(dest, path.basename(source))
|
||||
}
|
||||
|
||||
await copyDirectoryContents(source, dest, force, moveOptions.deleteOriginal)
|
||||
} else {
|
||||
if (force) {
|
||||
await ioUtil.copyFile(source, dest)
|
||||
} else {
|
||||
await ioUtil.copyFile(source, dest, fs.constants.COPYFILE_EXCL)
|
||||
}
|
||||
|
||||
if (moveOptions.deleteOriginal) {
|
||||
await ioUtil.unlink(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readCopyOptions(options: CopyOptions): Required<CopyOptions> {
|
||||
const force = options.force == null ? true : options.force
|
||||
const recursive = Boolean(options.recursive)
|
||||
return {force, recursive}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./lib",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": [
|
||||
"./src"
|
||||
]
|
||||
}
|
Loading…
Reference in New Issue