1
0
Fork 0
* 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 mkdirP
pull/11/head
Danny McCormick 2019-05-22 16:05:34 -04:00 committed by GitHub
parent 4f5f4f2fb8
commit 08db5110c6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 2059 additions and 3 deletions

View File

@ -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",

3
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules/
packages/*/node_modules/
packages/*/lib/
packages/*/lib/
packages/*/__tests__/_temp/

49
packages/io/README.md Normal file
View File

@ -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

34
packages/io/package.json Normal file
View File

@ -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"
}
}

211
packages/io/src/io-util.ts Normal file
View File

@ -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())
)
}

286
packages/io/src/io.ts Normal file
View File

@ -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}
}

11
packages/io/tsconfig.json Normal file
View File

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "./",
"outDir": "./lib",
"rootDir": "./src"
},
"include": [
"./src"
]
}