From 08db5110c680107ebfddd5df53878845e04f810e Mon Sep 17 00:00:00 2001 From: Danny McCormick Date: Wed, 22 May 2019 16:05:34 -0400 Subject: [PATCH] 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 mkdirP --- .eslintrc.json | 2 - .gitignore | 3 +- packages/io/README.md | 49 + packages/io/__tests__/io.test.ts | 1466 ++++++++++++++++++++++++++++++ packages/io/package.json | 34 + packages/io/src/io-util.ts | 211 +++++ packages/io/src/io.ts | 286 ++++++ packages/io/tsconfig.json | 11 + 8 files changed, 2059 insertions(+), 3 deletions(-) create mode 100644 packages/io/README.md create mode 100644 packages/io/__tests__/io.test.ts create mode 100644 packages/io/package.json create mode 100644 packages/io/src/io-util.ts create mode 100644 packages/io/src/io.ts create mode 100644 packages/io/tsconfig.json diff --git a/.eslintrc.json b/.eslintrc.json index a427f9e5..02c79de1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -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", diff --git a/.gitignore b/.gitignore index ea4d4ad1..db6371d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ packages/*/node_modules/ -packages/*/lib/ \ No newline at end of file +packages/*/lib/ +packages/*/__tests__/_temp/ \ No newline at end of file diff --git a/packages/io/README.md b/packages/io/README.md new file mode 100644 index 00000000..e9b50d2f --- /dev/null +++ b/packages/io/README.md @@ -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 + +/** + * Remove a path recursively with force + * + * @param path path to remove + */ +export function rmRF(path: string): Promise + +/** + * Make a directory. Creates the full path with folders in between + * + * @param p path to create + * @returns Promise + */ +export function mkdirP(p: string): Promise + +/** + * 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 + +/** + * 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 path to tool + */ +export function which(tool: string, options?: WhichOptions): Promise +``` \ No newline at end of file diff --git a/packages/io/__tests__/io.test.ts b/packages/io/__tests__/io.test.ts new file mode 100644 index 00000000..95218250 --- /dev/null +++ b/packages/io/__tests__/io.test.ts @@ -0,0 +1,1466 @@ +import * as child from 'child_process' +import {promises as fs} from 'fs' +import * as os from 'os' +import * as path from 'path' +import * as io from '../src/io' +import * as ioUtil from '../src/io-util' + +describe('cp', () => { + it('copies file with no flags', async () => { + const root = path.join(getTestTemp(), 'cp_with_no_flags') + const sourceFile = path.join(root, 'cp_source') + const targetFile = path.join(root, 'cp_target') + await io.mkdirP(root) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + + await io.cp(sourceFile, targetFile) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + }) + + it('copies file using -f', async () => { + const root: string = path.join(path.join(__dirname, '_temp'), 'cp_with_-f') + const sourceFile: string = path.join(root, 'cp_source') + const targetFile: string = path.join(root, 'cp_target') + await io.mkdirP(root) + await fs.writeFile(sourceFile, 'test file content') + + await io.cp(sourceFile, targetFile, {recursive: false, force: true}) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + }) + + it('try copying to existing file with -n', async () => { + const root: string = path.join(getTestTemp(), 'cp_to_existing') + const sourceFile: string = path.join(root, 'cp_source') + const targetFile: string = path.join(root, 'cp_target') + await io.mkdirP(root) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + await fs.writeFile(targetFile, 'correct content', {encoding: 'utf8'}) + let failed = false + try { + await io.cp(sourceFile, targetFile, {recursive: false, force: false}) + } catch { + failed = true + } + expect(failed).toBe(true) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'correct content' + ) + }) + + it('copies directory into existing destination with -r', async () => { + const root: string = path.join(getTestTemp(), 'cp_with_-r_existing_dest') + const sourceFolder: string = path.join(root, 'cp_source') + const sourceFile: string = path.join(sourceFolder, 'cp_source_file') + + const targetFolder: string = path.join(root, 'cp_target') + const targetFile: string = path.join( + targetFolder, + 'cp_source', + 'cp_source_file' + ) + await io.mkdirP(sourceFolder) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + await io.mkdirP(targetFolder) + await io.cp(sourceFolder, targetFolder, {recursive: true}) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + }) + + it('copies directory into non-existing destination with -r', async () => { + const root: string = path.join(getTestTemp(), 'cp_with_-r_nonexisting_dest') + const sourceFolder: string = path.join(root, 'cp_source') + const sourceFile: string = path.join(sourceFolder, 'cp_source_file') + + const targetFolder: string = path.join(root, 'cp_target') + const targetFile: string = path.join(targetFolder, 'cp_source_file') + await io.mkdirP(sourceFolder) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + await io.cp(sourceFolder, targetFolder, {recursive: true}) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + }) + + it('tries to copy directory without -r', async () => { + const root: string = path.join(getTestTemp(), 'cp_without_-r') + const sourceFolder: string = path.join(root, 'cp_source') + const sourceFile: string = path.join(sourceFolder, 'cp_source_file') + + const targetFolder: string = path.join(root, 'cp_target') + const targetFile: string = path.join( + targetFolder, + 'cp_source', + 'cp_source_file' + ) + await io.mkdirP(sourceFolder) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + + let thrown = false + try { + await io.cp(sourceFolder, targetFolder) + } catch (err) { + thrown = true + } + expect(thrown).toBe(true) + await assertNotExists(targetFile) + }) +}) + +describe('mv', () => { + it('moves file with no flags', async () => { + const root = path.join(getTestTemp(), ' mv_with_no_flags') + const sourceFile = path.join(root, ' mv_source') + const targetFile = path.join(root, ' mv_target') + await io.mkdirP(root) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + + await io.mv(sourceFile, targetFile) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + await assertNotExists(sourceFile) + }) + + it('moves file using -f', async () => { + const root: string = path.join(path.join(__dirname, '_temp'), ' mv_with_-f') + const sourceFile: string = path.join(root, ' mv_source') + const targetFile: string = path.join(root, ' mv_target') + await io.mkdirP(root) + await fs.writeFile(sourceFile, 'test file content') + + await io.mv(sourceFile, targetFile) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + + await assertNotExists(sourceFile) + }) + + it('try moving to existing file with -n', async () => { + const root: string = path.join(getTestTemp(), ' mv_to_existing') + const sourceFile: string = path.join(root, ' mv_source') + const targetFile: string = path.join(root, ' mv_target') + await io.mkdirP(root) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + await fs.writeFile(targetFile, 'correct content', {encoding: 'utf8'}) + let failed = false + try { + await io.mv(sourceFile, targetFile, {force: false}) + } catch { + failed = true + } + expect(failed).toBe(true) + + expect(await fs.readFile(sourceFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'correct content' + ) + }) + + it('moves directory into existing destination with -r', async () => { + const root: string = path.join(getTestTemp(), ' mv_with_-r_existing_dest') + const sourceFolder: string = path.join(root, ' mv_source') + const sourceFile: string = path.join(sourceFolder, ' mv_source_file') + + const targetFolder: string = path.join(root, ' mv_target') + const targetFile: string = path.join( + targetFolder, + ' mv_source', + ' mv_source_file' + ) + await io.mkdirP(sourceFolder) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + await io.mkdirP(targetFolder) + await io.mv(sourceFolder, targetFolder, {recursive: true}) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + await assertNotExists(sourceFile) + }) + + it('moves directory into non-existing destination with -r', async () => { + const root: string = path.join( + getTestTemp(), + ' mv_with_-r_nonexisting_dest' + ) + const sourceFolder: string = path.join(root, ' mv_source') + const sourceFile: string = path.join(sourceFolder, ' mv_source_file') + + const targetFolder: string = path.join(root, ' mv_target') + const targetFile: string = path.join(targetFolder, ' mv_source_file') + await io.mkdirP(sourceFolder) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + await io.mv(sourceFolder, targetFolder, {recursive: true}) + + expect(await fs.readFile(targetFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + await assertNotExists(sourceFile) + }) + + it('tries to move directory without -r', async () => { + const root: string = path.join(getTestTemp(), 'mv_without_-r') + const sourceFolder: string = path.join(root, 'mv_source') + const sourceFile: string = path.join(sourceFolder, 'mv_source_file') + + const targetFolder: string = path.join(root, 'mv_target') + const targetFile: string = path.join( + targetFolder, + 'mv_source', + 'mv_source_file' + ) + await io.mkdirP(sourceFolder) + await fs.writeFile(sourceFile, 'test file content', {encoding: 'utf8'}) + + let thrown = false + try { + await io.mv(sourceFolder, targetFolder) + } catch (err) { + thrown = true + } + + expect(thrown).toBe(true) + await assertExists(sourceFile) + await assertNotExists(targetFile) + }) +}) + +describe('rmRF', () => { + it('removes single folder with rmRF', async () => { + const testPath = path.join(getTestTemp(), 'testFolder') + + await io.mkdirP(testPath) + await assertExists(testPath) + + await io.rmRF(testPath) + await assertNotExists(testPath) + }) + + it('removes recursive folders with rmRF', async () => { + const testPath = path.join(getTestTemp(), 'testDir1') + const testPath2 = path.join(testPath, 'testDir2') + await io.mkdirP(testPath2) + + await assertExists(testPath) + await assertExists(testPath2) + + await io.rmRF(testPath) + await assertNotExists(testPath) + await assertNotExists(testPath2) + }) + + it('removes folder with locked file with rmRF', async () => { + const testPath = path.join(getTestTemp(), 'testFolder') + await io.mkdirP(testPath) + await assertExists(testPath) + + // can't remove folder with locked file on windows + const filePath = path.join(testPath, 'file.txt') + await fs.appendFile(filePath, 'some data') + await assertExists(filePath) + + const fd = await fs.open(filePath, 'r') + + let worked: boolean + + try { + await io.rmRF(testPath) + worked = true + } catch (err) { + worked = false + } + + if (os.platform() === 'win32') { + expect(worked).toBe(false) + await assertExists(testPath) + } else { + expect(worked).toBe(true) + await assertNotExists(testPath) + } + + await fd.close() + await io.rmRF(testPath) + await assertNotExists(testPath) + }) + + it('removes folder that doesnt exist with rmRF', async () => { + const testPath = path.join(getTestTemp(), 'testFolder') + await assertNotExists(testPath) + + await io.rmRF(testPath) + await assertNotExists(testPath) + }) + + it('removes file with rmRF', async () => { + const file: string = path.join(getTestTemp(), 'rmRF_file') + await fs.writeFile(file, 'test file content') + await assertExists(file) + await io.rmRF(file) + await assertNotExists(file) + }) + + it('removes hidden folder with rmRF', async () => { + const directory: string = path.join(getTestTemp(), '.rmRF_directory') + await createHiddenDirectory(directory) + await assertExists(directory) + await io.rmRF(directory) + await assertNotExists(directory) + }) + + it('removes hidden file with rmRF', async () => { + const file: string = path.join(getTestTemp(), '.rmRF_file') + await fs.writeFile(file, 'test file content') + await assertExists(file) + await io.rmRF(file) + await assertNotExists(file) + }) + + it('removes symlink folder with rmRF', async () => { + // create the following layout: + // real_directory + // real_directory/real_file + // symlink_directory -> real_directory + const root: string = path.join(getTestTemp(), 'rmRF_sym_dir_test') + const realDirectory: string = path.join(root, 'real_directory') + const realFile: string = path.join(root, 'real_directory', 'real_file') + const symlinkDirectory: string = path.join(root, 'symlink_directory') + await io.mkdirP(realDirectory) + await fs.writeFile(realFile, 'test file content') + await createSymlinkDir(realDirectory, symlinkDirectory) + await assertExists(path.join(symlinkDirectory, 'real_file')) + + await io.rmRF(symlinkDirectory) + await assertExists(realDirectory) + await assertExists(realFile) + await assertNotExists(symlinkDirectory) + }) + + // creating a symlink to a file on Windows requires elevated + if (os.platform() !== 'win32') { + it('removes symlink file with rmRF', async () => { + // create the following layout: + // real_file + // symlink_file -> real_file + const root: string = path.join(getTestTemp(), 'rmRF_sym_file_test') + const realFile: string = path.join(root, 'real_file') + const symlinkFile: string = path.join(root, 'symlink_file') + await io.mkdirP(root) + await fs.writeFile(realFile, 'test file content') + await fs.symlink(realFile, symlinkFile) + expect(await fs.readFile(symlinkFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + + await io.rmRF(symlinkFile) + await assertExists(realFile) + await assertNotExists(symlinkFile) + }) + + it('removes symlink file with missing source using rmRF', async () => { + // create the following layout: + // real_file + // symlink_file -> real_file + const root: string = path.join( + getTestTemp(), + 'rmRF_sym_file_missing_source_test' + ) + const realFile: string = path.join(root, 'real_file') + const symlinkFile: string = path.join(root, 'symlink_file') + await io.mkdirP(root) + await fs.writeFile(realFile, 'test file content') + await fs.symlink(realFile, symlinkFile) + expect(await fs.readFile(symlinkFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + + // remove the real file + await fs.unlink(realFile) + expect((await fs.lstat(symlinkFile)).isSymbolicLink()).toBe(true) + + // remove the symlink file + await io.rmRF(symlinkFile) + let errcode = '' + try { + await fs.lstat(symlinkFile) + } catch (err) { + errcode = err.code + } + + expect(errcode).toBe('ENOENT') + }) + + it('removes symlink level 2 file with rmRF', async () => { + // create the following layout: + // real_file + // symlink_file -> real_file + // symlink_level_2_file -> symlink_file + const root: string = path.join( + getTestTemp(), + 'rmRF_sym_level_2_file_test' + ) + const realFile: string = path.join(root, 'real_file') + const symlinkFile: string = path.join(root, 'symlink_file') + const symlinkLevel2File: string = path.join(root, 'symlink_level_2_file') + await io.mkdirP(root) + await fs.writeFile(realFile, 'test file content') + await fs.symlink(realFile, symlinkFile) + await fs.symlink(symlinkFile, symlinkLevel2File) + expect(await fs.readFile(symlinkLevel2File, {encoding: 'utf8'})).toBe( + 'test file content' + ) + + await io.rmRF(symlinkLevel2File) + await assertExists(realFile) + await assertExists(symlinkFile) + await assertNotExists(symlinkLevel2File) + }) + + it('removes nested symlink file with rmRF', async () => { + // create the following layout: + // real_directory + // real_directory/real_file + // outer_directory + // outer_directory/symlink_file -> real_file + const root: string = path.join(getTestTemp(), 'rmRF_sym_nest_file_test') + const realDirectory: string = path.join(root, 'real_directory') + const realFile: string = path.join(root, 'real_directory', 'real_file') + const outerDirectory: string = path.join(root, 'outer_directory') + const symlinkFile: string = path.join( + root, + 'outer_directory', + 'symlink_file' + ) + await io.mkdirP(realDirectory) + await fs.writeFile(realFile, 'test file content') + await io.mkdirP(outerDirectory) + await fs.symlink(realFile, symlinkFile) + expect(await fs.readFile(symlinkFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + + await io.rmRF(outerDirectory) + await assertExists(realDirectory) + await assertExists(realFile) + await assertNotExists(symlinkFile) + await assertNotExists(outerDirectory) + }) + + it('removes deeply nested symlink file with rmRF', async () => { + // create the following layout: + // real_directory + // real_directory/real_file + // outer_directory + // outer_directory/nested_directory + // outer_directory/nested_directory/symlink_file -> real_file + const root: string = path.join( + getTestTemp(), + 'rmRF_sym_deep_nest_file_test' + ) + const realDirectory: string = path.join(root, 'real_directory') + const realFile: string = path.join(root, 'real_directory', 'real_file') + const outerDirectory: string = path.join(root, 'outer_directory') + const nestedDirectory: string = path.join( + root, + 'outer_directory', + 'nested_directory' + ) + const symlinkFile: string = path.join( + root, + 'outer_directory', + 'nested_directory', + 'symlink_file' + ) + await io.mkdirP(realDirectory) + await fs.writeFile(realFile, 'test file content') + await io.mkdirP(nestedDirectory) + await fs.symlink(realFile, symlinkFile) + expect(await fs.readFile(symlinkFile, {encoding: 'utf8'})).toBe( + 'test file content' + ) + + await io.rmRF(outerDirectory) + await assertExists(realDirectory) + await assertExists(realFile) + await assertNotExists(symlinkFile) + await assertNotExists(outerDirectory) + }) + } + + it('removes symlink folder with missing source using rmRF', async () => { + // create the following layout: + // real_directory + // real_directory/real_file + // symlink_directory -> real_directory + const root: string = path.join(getTestTemp(), 'rmRF_sym_dir_miss_src_test') + const realDirectory: string = path.join(root, 'real_directory') + const realFile: string = path.join(root, 'real_directory', 'real_file') + const symlinkDirectory: string = path.join(root, 'symlink_directory') + await io.mkdirP(realDirectory) + await fs.writeFile(realFile, 'test file content') + await createSymlinkDir(realDirectory, symlinkDirectory) + await assertExists(symlinkDirectory) + + // remove the real directory + await fs.unlink(realFile) + await fs.rmdir(realDirectory) + let errcode = '' + try { + await fs.stat(symlinkDirectory) + } catch (err) { + errcode = err.code + } + + expect(errcode).toBe('ENOENT') + + // lstat shouldn't throw + await fs.lstat(symlinkDirectory) + + // remove the symlink directory + await io.rmRF(symlinkDirectory) + errcode = '' + try { + await fs.lstat(symlinkDirectory) + } catch (err) { + errcode = err.code + } + + expect(errcode).toBe('ENOENT') + }) + + it('removes symlink level 2 folder with rmRF', async () => { + // create the following layout: + // real_directory + // real_directory/real_file + // symlink_directory -> real_directory + // symlink_level_2_directory -> symlink_directory + const root: string = path.join( + getTestTemp(), + 'rmRF_sym_level_2_directory_test' + ) + const realDirectory: string = path.join(root, 'real_directory') + const realFile: string = path.join(realDirectory, 'real_file') + const symlinkDirectory: string = path.join(root, 'symlink_directory') + const symlinkLevel2Directory: string = path.join( + root, + 'symlink_level_2_directory' + ) + await io.mkdirP(realDirectory) + await fs.writeFile(realFile, 'test file content') + await createSymlinkDir(realDirectory, symlinkDirectory) + await createSymlinkDir(symlinkDirectory, symlinkLevel2Directory) + expect( + await fs.readFile(path.join(symlinkDirectory, 'real_file'), { + encoding: 'utf8' + }) + ).toBe('test file content') + if (os.platform() === 'win32') { + expect(await fs.readlink(symlinkLevel2Directory)).toBe( + `${symlinkDirectory}\\` + ) + } else { + expect(await fs.readlink(symlinkLevel2Directory)).toBe(symlinkDirectory) + } + + await io.rmRF(symlinkLevel2Directory) + await assertExists(path.join(symlinkDirectory, 'real_file')) + await assertNotExists(symlinkLevel2Directory) + }) + + it('removes nested symlink folder with rmRF', async () => { + // create the following layout: + // real_directory + // real_directory/real_file + // outer_directory + // outer_directory/symlink_directory -> real_directory + const root: string = path.join(getTestTemp(), 'rmRF_sym_nest_dir_test') + const realDirectory: string = path.join(root, 'real_directory') + const realFile: string = path.join(root, 'real_directory', 'real_file') + const outerDirectory: string = path.join(root, 'outer_directory') + const symlinkDirectory: string = path.join( + root, + 'outer_directory', + 'symlink_directory' + ) + await io.mkdirP(realDirectory) + await fs.writeFile(realFile, 'test file content') + await io.mkdirP(outerDirectory) + await createSymlinkDir(realDirectory, symlinkDirectory) + await assertExists(path.join(symlinkDirectory, 'real_file')) + + await io.rmRF(outerDirectory) + await assertExists(realDirectory) + await assertExists(realFile) + await assertNotExists(symlinkDirectory) + await assertNotExists(outerDirectory) + }) + + it('removes deeply nested symlink folder with rmRF', async () => { + // create the following layout: + // real_directory + // real_directory/real_file + // outer_directory + // outer_directory/nested_directory + // outer_directory/nested_directory/symlink_directory -> real_directory + const root: string = path.join(getTestTemp(), 'rmRF_sym_deep_nest_dir_test') + const realDirectory: string = path.join(root, 'real_directory') + const realFile: string = path.join(root, 'real_directory', 'real_file') + const outerDirectory: string = path.join(root, 'outer_directory') + const nestedDirectory: string = path.join( + root, + 'outer_directory', + 'nested_directory' + ) + const symlinkDirectory: string = path.join( + root, + 'outer_directory', + 'nested_directory', + 'symlink_directory' + ) + await io.mkdirP(realDirectory) + await fs.writeFile(realFile, 'test file content') + await io.mkdirP(nestedDirectory) + await createSymlinkDir(realDirectory, symlinkDirectory) + await assertExists(path.join(symlinkDirectory, 'real_file')) + + await io.rmRF(outerDirectory) + await assertExists(realDirectory) + await assertExists(realFile) + await assertNotExists(symlinkDirectory) + await assertNotExists(outerDirectory) + }) + + it('removes hidden file with rmRF', async () => { + const file: string = path.join(getTestTemp(), '.rmRF_file') + await io.mkdirP(path.dirname(file)) + await createHiddenFile(file, 'test file content') + await assertExists(file) + await io.rmRF(file) + await assertNotExists(file) + }) +}) + +describe('mkdirP', () => { + beforeAll(async () => { + await io.rmRF(getTestTemp()) + }) + + it('fails when called with an empty path', async () => { + expect.assertions(1) + + try { + await io.mkdirP('') + } catch (err) { + expect(err.message).toEqual('a path argument must be provided') + } + }) + + it('creates folder', async () => { + const testPath = path.join(getTestTemp(), 'mkdirTest') + await io.mkdirP(testPath) + + await assertExists(testPath) + }) + + it('creates nested folders with mkdirP', async () => { + const testPath = path.join(getTestTemp(), 'mkdir1', 'mkdir2') + await io.mkdirP(testPath) + + await assertExists(testPath) + }) + + it('fails if mkdirP with illegal chars', async () => { + const testPath = path.join(getTestTemp(), 'mkdir\0') + let worked = false + try { + await io.mkdirP(testPath) + worked = true + } catch (err) { + await expect(fs.stat(testPath)).rejects.toHaveProperty( + 'code', + 'ERR_INVALID_ARG_VALUE' + ) + } + + expect(worked).toBe(false) + }) + + it('fails if mkdirP with conflicting file path', async () => { + const testPath = path.join(getTestTemp(), 'mkdirP_conflicting_file_path') + await io.mkdirP(getTestTemp()) + await fs.writeFile(testPath, '') + let worked: boolean + try { + await io.mkdirP(testPath) + worked = true + } catch (err) { + worked = false + } + + expect(worked).toBe(false) + }) + + it('fails if mkdirP with conflicting parent file path', async () => { + const testPath = path.join( + getTestTemp(), + 'mkdirP_conflicting_parent_file_path', + 'dir' + ) + await io.mkdirP(getTestTemp()) + await fs.writeFile(path.dirname(testPath), '') + let worked: boolean + try { + await io.mkdirP(testPath) + worked = true + } catch (err) { + worked = false + } + + expect(worked).toBe(false) + }) + + it('no-ops if mkdirP directory exists', async () => { + const testPath = path.join(getTestTemp(), 'mkdirP_dir_exists') + await io.mkdirP(testPath) + await assertExists(testPath) + + // Calling again shouldn't throw + await io.mkdirP(testPath) + await assertExists(testPath) + }) + + it('no-ops if mkdirP with symlink directory', async () => { + // create the following layout: + // real_dir + // real_dir/file.txt + // symlink_dir -> real_dir + const rootPath = path.join(getTestTemp(), 'mkdirP_symlink_dir') + const realDirPath = path.join(rootPath, 'real_dir') + const realFilePath = path.join(realDirPath, 'file.txt') + const symlinkDirPath = path.join(rootPath, 'symlink_dir') + await io.mkdirP(getTestTemp()) + await fs.mkdir(rootPath) + await fs.mkdir(realDirPath) + await fs.writeFile(realFilePath, 'test real_dir/file.txt contet') + await createSymlinkDir(realDirPath, symlinkDirPath) + + await io.mkdirP(symlinkDirPath) + + // the file in the real directory should still be accessible via the symlink + expect((await fs.lstat(symlinkDirPath)).isSymbolicLink()).toBe(true) + expect( + (await fs.stat(path.join(symlinkDirPath, 'file.txt'))).isFile() + ).toBe(true) + }) + + it('no-ops if mkdirP with parent symlink directory', async () => { + // create the following layout: + // real_dir + // real_dir/file.txt + // symlink_dir -> real_dir + const rootPath = path.join(getTestTemp(), 'mkdirP_parent_symlink_dir') + const realDirPath = path.join(rootPath, 'real_dir') + const realFilePath = path.join(realDirPath, 'file.txt') + const symlinkDirPath = path.join(rootPath, 'symlink_dir') + await io.mkdirP(getTestTemp()) + await fs.mkdir(rootPath) + await fs.mkdir(realDirPath) + await fs.writeFile(realFilePath, 'test real_dir/file.txt contet') + await createSymlinkDir(realDirPath, symlinkDirPath) + + const subDirPath = path.join(symlinkDirPath, 'sub_dir') + await io.mkdirP(subDirPath) + + // the subdirectory should be accessible via the real directory + expect( + (await fs.lstat(path.join(realDirPath, 'sub_dir'))).isDirectory() + ).toBe(true) + }) + + it('breaks if mkdirP loop out of control', async () => { + const testPath = path.join( + getTestTemp(), + 'mkdirP_failsafe', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + '10' + ) + + expect.assertions(1) + + try { + await ioUtil.mkdirP(testPath, 10) + } catch (err) { + expect(err.code).toBe('ENOENT') + } + }) +}) + +describe('which', () => { + it('which() finds file name', async () => { + // create a executable file + const testPath = path.join(getTestTemp(), 'which-finds-file-name') + await io.mkdirP(testPath) + let fileName = 'Which-Test-File' + if (process.platform === 'win32') { + fileName += '.exe' + } + + const filePath = path.join(testPath, fileName) + await fs.writeFile(filePath, '') + if (process.platform !== 'win32') { + chmod(filePath, '+x') + } + + const originalPath = process.env['PATH'] + try { + // update the PATH + process.env['PATH'] = `${process.env['PATH']}${path.delimiter}${testPath}` + + // exact file name + expect(await io.which(fileName)).toBe(filePath) + expect(await io.which(fileName, false)).toBe(filePath) + expect(await io.which(fileName, true)).toBe(filePath) + + if (process.platform === 'win32') { + // not case sensitive on windows + expect(await io.which('which-test-file.exe')).toBe( + path.join(testPath, 'which-test-file.exe') + ) + expect(await io.which('WHICH-TEST-FILE.EXE')).toBe( + path.join(testPath, 'WHICH-TEST-FILE.EXE') + ) + expect(await io.which('WHICH-TEST-FILE.EXE', false)).toBe( + path.join(testPath, 'WHICH-TEST-FILE.EXE') + ) + expect(await io.which('WHICH-TEST-FILE.EXE', true)).toBe( + path.join(testPath, 'WHICH-TEST-FILE.EXE') + ) + + // without extension + expect(await io.which('which-test-file')).toBe(filePath) + expect(await io.which('which-test-file', false)).toBe(filePath) + expect(await io.which('which-test-file', true)).toBe(filePath) + } else if (process.platform === 'darwin') { + // not case sensitive on Mac + expect(await io.which(fileName.toUpperCase())).toBe( + path.join(testPath, fileName.toUpperCase()) + ) + expect(await io.which(fileName.toUpperCase(), false)).toBe( + path.join(testPath, fileName.toUpperCase()) + ) + expect(await io.which(fileName.toUpperCase(), true)).toBe( + path.join(testPath, fileName.toUpperCase()) + ) + } else { + // case sensitive on Linux + expect(await io.which(fileName.toUpperCase())).toBe('') + } + } finally { + process.env['PATH'] = originalPath + } + }) + + it('which() not found', async () => { + expect(await io.which('which-test-no-such-file')).toBe('') + expect(await io.which('which-test-no-such-file', false)).toBe('') + await expect( + io.which('which-test-no-such-file', true) + ).rejects.toBeDefined() + }) + + it('which() searches path in order', async () => { + // create a chcp.com/bash override file + const testPath = path.join(getTestTemp(), 'which-searches-path-in-order') + await io.mkdirP(testPath) + let fileName + if (process.platform === 'win32') { + fileName = 'chcp.com' + } else { + fileName = 'bash' + } + + const filePath = path.join(testPath, fileName) + await fs.writeFile(filePath, '') + if (process.platform !== 'win32') { + chmod(filePath, '+x') + } + + const originalPath = process.env['PATH'] + try { + // sanity - regular chcp.com/bash should be found + const originalWhich = await io.which(fileName) + expect(!!(originalWhich || '')).toBe(true) + + // modify PATH + process.env['PATH'] = [testPath, process.env.PATH].join(path.delimiter) + + // override chcp.com/bash should be found + expect(await io.which(fileName)).toBe(filePath) + } finally { + process.env['PATH'] = originalPath + } + }) + + it('which() requires executable', async () => { + // create a non-executable file + // on Windows, should not end in valid PATHEXT + // on Mac/Linux should not have executable bit + const testPath = path.join(getTestTemp(), 'which-requires-executable') + await io.mkdirP(testPath) + let fileName = 'Which-Test-File' + if (process.platform === 'win32') { + fileName += '.abc' // not a valid PATHEXT + } + + const filePath = path.join(testPath, fileName) + await fs.writeFile(filePath, '') + if (process.platform !== 'win32') { + chmod(filePath, '-x') + } + + const originalPath = process.env['PATH'] + try { + // modify PATH + process.env['PATH'] = [process.env['PATH'], testPath].join(path.delimiter) + + // should not be found + expect(await io.which(fileName)).toBe('') + } finally { + process.env['PATH'] = originalPath + } + }) + + // which permissions tests + it('which() finds executable with different permissions', async () => { + await findsExecutableWithScopedPermissions('u=rwx,g=r,o=r') + await findsExecutableWithScopedPermissions('u=rw,g=rx,o=r') + await findsExecutableWithScopedPermissions('u=rw,g=r,o=rx') + }) + + it('which() ignores directory match', async () => { + // create a directory + const testPath = path.join(getTestTemp(), 'which-ignores-directory-match') + let dirPath = path.join(testPath, 'Which-Test-Dir') + if (process.platform === 'win32') { + dirPath += '.exe' + } + + await io.mkdirP(dirPath) + if (process.platform !== 'win32') { + chmod(dirPath, '+x') + } + + const originalPath = process.env['PATH'] + try { + // modify PATH + process.env['PATH'] = [process.env['PATH'], testPath].join(path.delimiter) + + // should not be found + expect(await io.which(path.basename(dirPath))).toBe('') + } finally { + process.env['PATH'] = originalPath + } + }) + + it('which() allows rooted path', async () => { + // create an executable file + const testPath = path.join(getTestTemp(), 'which-allows-rooted-path') + await io.mkdirP(testPath) + let filePath = path.join(testPath, 'Which-Test-File') + if (process.platform === 'win32') { + filePath += '.exe' + } + + await fs.writeFile(filePath, '') + if (process.platform !== 'win32') { + chmod(filePath, '+x') + } + + // which the full path + expect(await io.which(filePath)).toBe(filePath) + expect(await io.which(filePath, false)).toBe(filePath) + expect(await io.which(filePath, true)).toBe(filePath) + }) + + it('which() requires rooted path to be executable', async () => { + // create a non-executable file + // on Windows, should not end in valid PATHEXT + // on Mac/Linux, should not have executable bit + const testPath = path.join( + getTestTemp(), + 'which-requires-rooted-path-to-be-executable' + ) + await io.mkdirP(testPath) + let filePath = path.join(testPath, 'Which-Test-File') + if (process.platform === 'win32') { + filePath += '.abc' // not a valid PATHEXT + } + + await fs.writeFile(filePath, '') + if (process.platform !== 'win32') { + chmod(filePath, '-x') + } + + // should not be found + expect(await io.which(filePath)).toBe('') + expect(await io.which(filePath, false)).toBe('') + let failed = false + try { + await io.which(filePath, true) + } catch (err) { + failed = true + } + + expect(failed).toBe(true) + }) + + it('which() requires rooted path to be a file', async () => { + // create a dir + const testPath = path.join( + getTestTemp(), + 'which-requires-rooted-path-to-be-executable' + ) + let dirPath = path.join(testPath, 'Which-Test-Dir') + if (process.platform === 'win32') { + dirPath += '.exe' + } + + await io.mkdirP(dirPath) + if (process.platform !== 'win32') { + chmod(dirPath, '+x') + } + + // should not be found + expect(await io.which(dirPath)).toBe('') + expect(await io.which(dirPath, false)).toBe('') + let failed = false + try { + await io.which(dirPath, true) + } catch (err) { + failed = true + } + + expect(failed).toBe(true) + }) + + it('which() requires rooted path to exist', async () => { + let filePath = path.join(__dirname, 'no-such-file') + if (process.platform === 'win32') { + filePath += '.exe' + } + + expect(await io.which(filePath)).toBe('') + expect(await io.which(filePath, false)).toBe('') + let failed = false + try { + await io.which(filePath, true) + } catch (err) { + failed = true + } + + expect(failed).toBe(true) + }) + + it('which() does not allow separators', async () => { + // create an executable file + const testDirName = 'which-does-not-allow-separators' + const testPath = path.join(getTestTemp(), testDirName) + await io.mkdirP(testPath) + let fileName = 'Which-Test-File' + if (process.platform === 'win32') { + fileName += '.exe' + } + + const filePath = path.join(testPath, fileName) + await fs.writeFile(filePath, '') + if (process.platform !== 'win32') { + chmod(filePath, '+x') + } + + const originalPath = process.env['PATH'] + try { + // modify PATH + process.env['PATH'] = [process.env['PATH'], testPath].join(path.delimiter) + + // which "dir/file", should not be found + expect(await io.which(`${testDirName}/${fileName}`)).toBe('') + + // on Windows, also try "dir\file" + if (process.platform === 'win32') { + expect(await io.which(`${testDirName}\\${fileName}`)).toBe('') + } + } finally { + process.env['PATH'] = originalPath + } + }) + + if (process.platform === 'win32') { + it('which() resolves actual case file name when extension is applied', async () => { + const comspec: string = process.env['ComSpec'] || '' + expect(!!comspec).toBe(true) + expect(await io.which('CmD.eXe')).toBe( + path.join(path.dirname(comspec), 'CmD.eXe') + ) + expect(await io.which('CmD')).toBe(comspec) + }) + + it('which() appends ext on windows', async () => { + // create executable files + const testPath = path.join(getTestTemp(), 'which-appends-ext-on-windows') + await io.mkdirP(testPath) + // PATHEXT=.COM;.EXE;.BAT;.CMD... + const files: {[key: string]: string} = { + 'which-test-file-1': path.join(testPath, 'which-test-file-1.com'), + 'which-test-file-2': path.join(testPath, 'which-test-file-2.exe'), + 'which-test-file-3': path.join(testPath, 'which-test-file-3.bat'), + 'which-test-file-4': path.join(testPath, 'which-test-file-4.cmd'), + 'which-test-file-5.txt': path.join( + testPath, + 'which-test-file-5.txt.com' + ) + } + for (const fileName of Object.keys(files)) { + await fs.writeFile(files[fileName], '') + } + + const originalPath = process.env['PATH'] + try { + // modify PATH + process.env['PATH'] = `${process.env['PATH']}${ + path.delimiter + }${testPath}` + + // find each file + for (const fileName of Object.keys(files)) { + expect(await io.which(fileName)).toBe(files[fileName]) + } + } finally { + process.env['PATH'] = originalPath + } + }) + + it('which() appends ext on windows when rooted', async () => { + // create executable files + const testPath = path.join( + getTestTemp(), + 'which-appends-ext-on-windows-when-rooted' + ) + await io.mkdirP(testPath) + // PATHEXT=.COM;.EXE;.BAT;.CMD... + const files: {[key: string]: string} = {} + files[path.join(testPath, 'which-test-file-1')] = path.join( + testPath, + 'which-test-file-1.com' + ) + files[path.join(testPath, 'which-test-file-2')] = path.join( + testPath, + 'which-test-file-2.exe' + ) + files[path.join(testPath, 'which-test-file-3')] = path.join( + testPath, + 'which-test-file-3.bat' + ) + files[path.join(testPath, 'which-test-file-4')] = path.join( + testPath, + 'which-test-file-4.cmd' + ) + files[path.join(testPath, 'which-test-file-5.txt')] = path.join( + testPath, + 'which-test-file-5.txt.com' + ) + for (const fileName of Object.keys(files)) { + await fs.writeFile(files[fileName], '') + } + + // find each file + for (const fileName of Object.keys(files)) { + expect(await io.which(fileName)).toBe(files[fileName]) + } + }) + + it('which() prefer exact match on windows', async () => { + // create two executable files: + // which-test-file.bat + // which-test-file.bat.exe + // + // verify "which-test-file.bat" returns that file, and not "which-test-file.bat.exe" + // + // preference, within the same dir, should be given to the exact match (even though + // .EXE is defined with higher preference than .BAT in PATHEXT (PATHEXT=.COM;.EXE;.BAT;.CMD...) + const testPath = path.join( + getTestTemp(), + 'which-prefer-exact-match-on-windows' + ) + await io.mkdirP(testPath) + const fileName = 'which-test-file.bat' + const expectedFilePath = path.join(testPath, fileName) + const notExpectedFilePath = path.join(testPath, `${fileName}.exe`) + await fs.writeFile(expectedFilePath, '') + await fs.writeFile(notExpectedFilePath, '') + const originalPath = process.env['PATH'] + try { + process.env['PATH'] = `${process.env['PATH']}${ + path.delimiter + }${testPath}` + expect(await io.which(fileName)).toBe(expectedFilePath) + } finally { + process.env['PATH'] = originalPath + } + }) + + it('which() prefer exact match on windows when rooted', async () => { + // create two executable files: + // which-test-file.bat + // which-test-file.bat.exe + // + // verify "which-test-file.bat" returns that file, and not "which-test-file.bat.exe" + // + // preference, within the same dir, should be given to the exact match (even though + // .EXE is defined with higher preference than .BAT in PATHEXT (PATHEXT=.COM;.EXE;.BAT;.CMD...) + const testPath = path.join( + getTestTemp(), + 'which-prefer-exact-match-on-windows-when-rooted' + ) + await io.mkdirP(testPath) + const fileName = 'which-test-file.bat' + const expectedFilePath = path.join(testPath, fileName) + const notExpectedFilePath = path.join(testPath, `${fileName}.exe`) + await fs.writeFile(expectedFilePath, '') + await fs.writeFile(notExpectedFilePath, '') + expect(await io.which(path.join(testPath, fileName))).toBe( + expectedFilePath + ) + }) + + it('which() searches ext in order', async () => { + const testPath = path.join(getTestTemp(), 'which-searches-ext-in-order') + + // create a directory for testing .COM order preference + // PATHEXT=.COM;.EXE;.BAT;.CMD... + const fileNameWithoutExtension = 'which-test-file' + const comTestPath = path.join(testPath, 'com-test') + await io.mkdirP(comTestPath) + await fs.writeFile( + path.join(comTestPath, `${fileNameWithoutExtension}.com`), + '' + ) + await fs.writeFile( + path.join(comTestPath, `${fileNameWithoutExtension}.exe`), + '' + ) + await fs.writeFile( + path.join(comTestPath, `${fileNameWithoutExtension}.bat`), + '' + ) + await fs.writeFile( + path.join(comTestPath, `${fileNameWithoutExtension}.cmd`), + '' + ) + + // create a directory for testing .EXE order preference + // PATHEXT=.COM;.EXE;.BAT;.CMD... + const exeTestPath = path.join(testPath, 'exe-test') + await io.mkdirP(exeTestPath) + await fs.writeFile( + path.join(exeTestPath, `${fileNameWithoutExtension}.exe`), + '' + ) + await fs.writeFile( + path.join(exeTestPath, `${fileNameWithoutExtension}.bat`), + '' + ) + await fs.writeFile( + path.join(exeTestPath, `${fileNameWithoutExtension}.cmd`), + '' + ) + + // create a directory for testing .BAT order preference + // PATHEXT=.COM;.EXE;.BAT;.CMD... + const batTestPath = path.join(testPath, 'bat-test') + await io.mkdirP(batTestPath) + await fs.writeFile( + path.join(batTestPath, `${fileNameWithoutExtension}.bat`), + '' + ) + await fs.writeFile( + path.join(batTestPath, `${fileNameWithoutExtension}.cmd`), + '' + ) + + // create a directory for testing .CMD + const cmdTestPath = path.join(testPath, 'cmd-test') + await io.mkdirP(cmdTestPath) + const cmdFilePath = path.join( + cmdTestPath, + `${fileNameWithoutExtension}.cmd` + ) + await fs.writeFile(cmdFilePath, '') + + const originalPath = process.env['PATH'] + try { + // test .COM + process.env['PATH'] = `${comTestPath}${path.delimiter}${originalPath}` + expect(await io.which(fileNameWithoutExtension)).toBe( + path.join(comTestPath, `${fileNameWithoutExtension}.com`) + ) + + // test .EXE + process.env['PATH'] = `${exeTestPath}${path.delimiter}${originalPath}` + expect(await io.which(fileNameWithoutExtension)).toBe( + path.join(exeTestPath, `${fileNameWithoutExtension}.exe`) + ) + + // test .BAT + process.env['PATH'] = `${batTestPath}${path.delimiter}${originalPath}` + expect(await io.which(fileNameWithoutExtension)).toBe( + path.join(batTestPath, `${fileNameWithoutExtension}.bat`) + ) + + // test .CMD + process.env['PATH'] = `${cmdTestPath}${path.delimiter}${originalPath}` + expect(await io.which(fileNameWithoutExtension)).toBe( + path.join(cmdTestPath, `${fileNameWithoutExtension}.cmd`) + ) + } finally { + process.env['PATH'] = originalPath + } + }) + } +}) + +async function findsExecutableWithScopedPermissions( + chmodOptions: string +): Promise { + // create a executable file + const testPath = path.join(getTestTemp(), 'which-finds-file-name') + await io.mkdirP(testPath) + const fileName = 'Which-Test-File' + if (process.platform === 'win32') { + return + } + + const filePath = path.join(testPath, fileName) + await fs.writeFile(filePath, '') + chmod(filePath, chmodOptions) + + const originalPath = process.env['PATH'] + try { + // update the PATH + process.env['PATH'] = `${process.env['PATH']}${path.delimiter}${testPath}` + + // exact file name + expect(await io.which(fileName)).toBe(filePath) + expect(await io.which(fileName, false)).toBe(filePath) + expect(await io.which(fileName, true)).toBe(filePath) + + if (process.platform === 'darwin') { + // not case sensitive on Mac + expect(await io.which(fileName.toUpperCase())).toBe( + path.join(testPath, fileName.toUpperCase()) + ) + expect(await io.which(fileName.toUpperCase(), false)).toBe( + path.join(testPath, fileName.toUpperCase()) + ) + expect(await io.which(fileName.toUpperCase(), true)).toBe( + path.join(testPath, fileName.toUpperCase()) + ) + } else { + // case sensitive on Linux + expect(await io.which(fileName.toUpperCase())).toBe('') + } + } finally { + process.env['PATH'] = originalPath + } +} + +// Assert that a file exists +async function assertExists(filePath: string): Promise { + expect(await fs.stat(filePath)).toBeDefined() +} + +// Assert that reading a file raises an ENOENT error (does not exist) +async function assertNotExists(filePath: string): Promise { + await expect(fs.stat(filePath)).rejects.toHaveProperty('code', 'ENOENT') +} + +function chmod(file: string, mode: string): void { + const result = child.spawnSync('chmod', [mode, file]) + if (result.status !== 0) { + const message: string = (result.output || []).join(' ').trim() + throw new Error(`Command failed: "chmod ${mode} ${file}". ${message}`) + } +} + +async function createHiddenDirectory(dir: string): Promise { + if (!path.basename(dir).match(/^\./)) { + throw new Error(`Expected dir '${dir}' to start with '.'.`) + } + + await io.mkdirP(dir) + if (os.platform() === 'win32') { + const result = child.spawnSync('attrib.exe', ['+H', dir]) + if (result.status !== 0) { + const message: string = (result.output || []).join(' ').trim() + throw new Error( + `Failed to set hidden attribute for directory '${dir}'. ${message}` + ) + } + } +} + +async function createHiddenFile(file: string, content: string): Promise { + if (!path.basename(file).match(/^\./)) { + throw new Error(`Expected dir '${file}' to start with '.'.`) + } + + await io.mkdirP(path.dirname(file)) + await fs.writeFile(file, content) + + if (os.platform() === 'win32') { + const result = child.spawnSync('attrib.exe', ['+H', file]) + if (result.status !== 0) { + const message: string = (result.output || []).join(' ').trim() + throw new Error( + `Failed to set hidden attribute for file '${file}'. ${message}` + ) + } + } +} + +function getTestTemp(): string { + return path.join(__dirname, '_temp') +} + +/** + * Creates a symlink directory on OSX/Linux, and a junction point directory on Windows. + * A symlink directory is not created on Windows since it requires an elevated context. + */ +async function createSymlinkDir(real: string, link: string): Promise { + if (os.platform() === 'win32') { + await fs.symlink(real, link, 'junction') + } else { + await fs.symlink(real, link) + } +} diff --git a/packages/io/package.json b/packages/io/package.json new file mode 100644 index 00000000..ddf6a996 --- /dev/null +++ b/packages/io/package.json @@ -0,0 +1,34 @@ +{ + "name": "@actions/io", + "version": "1.0.0", + "description": "Actions io lib", + "keywords": [ + "io", + "actions" + ], + "author": "Danny McCormick ", + "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" + } +} diff --git a/packages/io/src/io-util.ts b/packages/io/src/io-util.ts new file mode 100644 index 00000000..d5d4e677 --- /dev/null +++ b/packages/io/src/io-util.ts @@ -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 { + 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 { + 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 { + 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 { + 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()) + ) +} diff --git a/packages/io/src/io.ts b/packages/io/src/io.ts new file mode 100644 index 00000000..25811854 --- /dev/null +++ b/packages/io/src/io.ts @@ -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 { + 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 { + 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 { + 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 + */ +export async function mkdirP(fsPath: string): Promise { + 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 path to tool + */ +export async function which(tool: string, check?: boolean): Promise { + 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 { + 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 { + 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 { + const force = options.force == null ? true : options.force + const recursive = Boolean(options.recursive) + return {force, recursive} +} diff --git a/packages/io/tsconfig.json b/packages/io/tsconfig.json new file mode 100644 index 00000000..a8b812a6 --- /dev/null +++ b/packages/io/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./lib", + "rootDir": "./src" + }, + "include": [ + "./src" + ] +} \ No newline at end of file