// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as child_process from 'node:child_process';
import * as path from 'node:path';
import { performance } from 'node:perf_hooks';
import { Transform } from 'node:stream';
import { JsonFile, FileSystem, FileConstants, SubprocessTerminator, Executable, Async } from '@rushstack/node-core-library';
import { syncNpmrc } from './npmrcUtilities';
import { EnvironmentVariableNames } from '../api/EnvironmentConfiguration';
import { RushConstants } from '../logic/RushConstants';
import { escapeArgumentIfNeeded, IS_WINDOWS } from './executionUtilities';
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const UNINITIALIZED = 'UNINITIALIZED';
export class Utilities {
    /**
     * Node.js equivalent of performance.now().
     */
    static getTimeInMs() {
        return performance.now();
    }
    /**
     * Retries a function until a timeout is reached. The function is expected to throw if it failed and
     *  should be retried.
     */
    static retryUntilTimeout(fn, maxWaitTimeMs, getTimeoutError, fnName) {
        const startTime = Utilities.getTimeInMs();
        let looped = false;
        let result;
        for (;;) {
            try {
                result = fn();
                break;
            }
            catch (e) {
                looped = true;
                const currentTime = Utilities.getTimeInMs();
                if (currentTime - startTime > maxWaitTimeMs) {
                    throw getTimeoutError(e);
                }
            }
        }
        if (looped) {
            const currentTime = Utilities.getTimeInMs();
            const totalSeconds = ((currentTime - startTime) / 1000.0).toFixed(2);
            // This logging statement isn't meaningful to the end-user. `fnName` should be updated
            // to something like `operationDescription`
            // eslint-disable-next-line no-console
            console.log(`${fnName}() stalled for ${totalSeconds} seconds`);
        }
        return result;
    }
    /**
     * Creates the specified folder by calling FileSystem.ensureFolder(), but using a
     * retry loop to recover from temporary locks that may be held by other processes.
     * If the folder already exists, no error occurs.
     */
    static createFolderWithRetry(folderName) {
        // Note: If a file exists with the same name, then we fall through and report
        // an error.
        if (Utilities.directoryExists(folderName)) {
            return;
        }
        // We need to do a simple "FileSystem.ensureFolder(localModulesFolder)" here,
        // however if the folder we deleted above happened to contain any files,
        // then there seems to be some OS process (virus scanner?) that holds
        // a lock on the folder for a split second, which causes mkdirSync to
        // fail.  To workaround that, retry for up to 7 seconds before giving up.
        const maxWaitTimeMs = 7 * 1000;
        return Utilities.retryUntilTimeout(() => FileSystem.ensureFolder(folderName), maxWaitTimeMs, (e) => new Error(`Error: ${e}\nOften this is caused by a file lock ` +
            'from a process such as your text editor, command prompt, ' +
            'or a filesystem watcher.'), 'createFolderWithRetry');
    }
    /**
     * Determines if a path points to a directory and that it exists.
     */
    static directoryExists(directoryPath) {
        let exists = false;
        try {
            const lstat = FileSystem.getLinkStatistics(directoryPath);
            exists = lstat.isDirectory();
        }
        catch (e) {
            /* no-op */
        }
        return exists;
    }
    /**
     * BE VERY CAREFUL CALLING THIS FUNCTION!
     * If you specify the wrong folderPath (e.g. "/"), it could potentially delete your entire
     * hard disk.
     */
    static dangerouslyDeletePath(folderPath) {
        try {
            FileSystem.deleteFolder(folderPath);
        }
        catch (e) {
            throw new Error(`${e.message}\nOften this is caused by a file lock from a process ` +
                'such as your text editor, command prompt, or a filesystem watcher');
        }
    }
    /*
     * Returns true if dateToCompare is more recent than all of the inputFilenames, which
     * would imply that we don't need to rebuild it. Returns false if any of the files
     * does not exist.
     * NOTE: The filenames can also be paths for directories, in which case the directory
     * timestamp is compared.
     */
    static async isFileTimestampCurrentAsync(dateToCompare, inputFilePaths) {
        let anyAreOutOfDate = false;
        await Async.forEachAsync(inputFilePaths, async (filePath) => {
            if (!anyAreOutOfDate) {
                let inputStats;
                try {
                    inputStats = await FileSystem.getStatisticsAsync(filePath);
                }
                catch (e) {
                    if (FileSystem.isNotExistError(e)) {
                        // eslint-disable-next-line require-atomic-updates
                        anyAreOutOfDate = true;
                    }
                    else {
                        throw e;
                    }
                }
                if (inputStats && dateToCompare < inputStats.mtime) {
                    // eslint-disable-next-line require-atomic-updates
                    anyAreOutOfDate = true;
                }
            }
        }, { concurrency: 10 });
        return !anyAreOutOfDate;
    }
    /**
     * Executes the command with the specified command-line parameters, and waits for it to complete.
     * The current directory will be set to the specified workingDirectory.
     */
    static async executeCommandAsync(options) {
        const { command, args, workingDirectory, suppressOutput, onStdoutStreamChunk, environment, keepEnvironment, captureExitCodeAndSignal } = options;
        const { exitCode, signal } = await Utilities._executeCommandInternalAsync({
            command,
            args,
            workingDirectory,
            stdio: onStdoutStreamChunk
                ? // Inherit the stdin and stderr streams, but pipe the stdout stream, which will then be piped
                    // to the process's stdout after being intercepted by the onStdoutStreamChunk callback.
                    ['inherit', 'pipe', 'inherit']
                : suppressOutput
                    ? // If the output is being suppressed, create pipes for all streams to prevent the child process
                        // from printing to the parent process's (this process's) stdout/stderr, but allow the stdout and
                        // stderr to be inspected if an error occurs.
                        // TODO: Consider ignoring stdout and stdin and only piping stderr for inspection on error.
                        ['pipe', 'pipe', 'pipe']
                    : // If the output is not being suppressed or intercepted, inherit all streams from the parent process.
                        ['inherit', 'inherit', 'inherit'],
            environment,
            keepEnvironment,
            onStdoutStreamChunk,
            captureOutput: false,
            captureExitCodeAndSignal
        });
        if (captureExitCodeAndSignal) {
            return { exitCode, signal };
        }
    }
    static async executeCommandAndCaptureOutputAsync(options) {
        const result = await Utilities._executeCommandInternalAsync({
            ...options,
            stdio: ['pipe', 'pipe', 'pipe'],
            captureOutput: true
        });
        if (options.captureExitCodeAndSignal) {
            return result;
        }
        else {
            return result.stdout;
        }
    }
    /**
     * Attempts to run Utilities.executeCommand() up to maxAttempts times before giving up.
     */
    static async executeCommandWithRetryAsync(options, maxAttempts, retryCallback) {
        if (maxAttempts < 1) {
            throw new Error('The maxAttempts parameter cannot be less than 1');
        }
        let attemptNumber = 1;
        for (;;) {
            try {
                await Utilities.executeCommandAsync(options);
            }
            catch (error) {
                // eslint-disable-next-line no-console
                console.log('\nThe command failed:');
                const { command, args } = options;
                // eslint-disable-next-line no-console
                console.log(` ${command} ` + args.join(' '));
                // eslint-disable-next-line no-console
                console.log(`ERROR: ${error.toString()}`);
                if (attemptNumber < maxAttempts) {
                    ++attemptNumber;
                    // eslint-disable-next-line no-console
                    console.log(`Trying again (attempt #${attemptNumber})...\n`);
                    if (retryCallback) {
                        retryCallback();
                    }
                    continue;
                }
                else {
                    // eslint-disable-next-line no-console
                    console.error(`Giving up after ${attemptNumber} attempts\n`);
                    throw error;
                }
            }
            break;
        }
    }
    /**
     * Executes the command using cmd if running on windows, or using sh if running on a non-windows OS.
     * @param command - the command to run on shell
     * @param options - options for how the command should be run
     */
    static executeLifecycleCommand(command, options) {
        const result = Utilities._executeLifecycleCommandInternal(command, child_process.spawnSync, options);
        if (options.handleOutput) {
            Utilities._processResult({
                error: result.error,
                status: result.status,
                stderr: result.stderr.toString()
            });
        }
        if (result.status !== null) {
            return result.status;
        }
        else {
            throw result.error || new Error('An unknown error occurred.');
        }
    }
    /**
     * Executes the command using cmd if running on windows, or using sh if running on a non-windows OS.
     * @param command - the command to run on shell
     * @param options - options for how the command should be run
     */
    static executeLifecycleCommandAsync(command, options) {
        const child = Utilities._executeLifecycleCommandInternal(command, child_process.spawn, options);
        if (options.connectSubprocessTerminator) {
            SubprocessTerminator.killProcessTreeOnExit(child, SubprocessTerminator.RECOMMENDED_OPTIONS);
        }
        return child;
    }
    /**
     * Installs a package by name and version in the specified directory.
     */
    static async installPackageInDirectoryAsync({ packageName, version, tempPackageTitle, commonRushConfigFolder, maxInstallAttempts, suppressOutput, directory, filterNpmIncompatibleProperties = false }) {
        directory = path.resolve(directory);
        const directoryExists = await FileSystem.existsAsync(directory);
        if (directoryExists) {
            // eslint-disable-next-line no-console
            console.log('Deleting old files from ' + directory);
        }
        await FileSystem.ensureEmptyFolderAsync(directory);
        const npmPackageJson = {
            dependencies: {
                [packageName]: version
            },
            description: 'Temporary file generated by the Rush tool',
            name: tempPackageTitle,
            private: true,
            version: '0.0.0'
        };
        await JsonFile.saveAsync(npmPackageJson, path.join(directory, FileConstants.PackageJson));
        if (commonRushConfigFolder) {
            Utilities.syncNpmrc({
                sourceNpmrcFolder: commonRushConfigFolder,
                targetNpmrcFolder: directory,
                supportEnvVarFallbackSyntax: false,
                // Filter out npm-incompatible properties only when the .npmrc is configured for
                // a different package manager (pnpm/yarn) but npm is being used to install.
                filterNpmIncompatibleProperties
            });
        }
        // eslint-disable-next-line no-console
        console.log('\nRunning "npm install" in ' + directory);
        // NOTE: Here we use whatever version of NPM we happen to find in the PATH
        await Utilities.executeCommandWithRetryAsync({
            command: 'npm',
            args: ['install'],
            workingDirectory: directory,
            environment: Utilities._createEnvironmentForRushCommand({}),
            suppressOutput
        }, maxInstallAttempts);
    }
    /**
     * Copies the file "sourcePath" to "destinationPath", overwriting the target file location.
     * If the source file does not exist, then the target file is deleted.
     */
    static syncFile(sourcePath, destinationPath) {
        if (FileSystem.exists(sourcePath)) {
            // eslint-disable-next-line no-console
            console.log(`Copying "${sourcePath}"`);
            // eslint-disable-next-line no-console
            console.log(`  --> "${destinationPath}"`);
            FileSystem.copyFile({ sourcePath, destinationPath });
        }
        else {
            if (FileSystem.exists(destinationPath)) {
                // If the source file doesn't exist and there is one in the target, delete the one in the target
                // eslint-disable-next-line no-console
                console.log(`Deleting ${destinationPath}`);
                FileSystem.deleteFile(destinationPath);
            }
        }
    }
    static getRushConfigNotFoundError() {
        return new Error(`Unable to find ${RushConstants.rushJsonFilename} configuration file`);
    }
    static async usingAsync(getDisposableAsync, doActionAsync) {
        let disposable;
        try {
            disposable = (await getDisposableAsync());
            await doActionAsync(disposable);
        }
        finally {
            disposable === null || disposable === void 0 ? void 0 : disposable.dispose();
        }
    }
    static trimAfterLastSlash(filePath) {
        const indexOfLastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'));
        if (indexOfLastSlash < 0) {
            return filePath;
        }
        return filePath.substring(0, indexOfLastSlash);
    }
    /**
     * If the path refers to a symlink, `FileSystem.exists()` would normally test whether the symlink
     * points to a target that exists. By contrast, `existsOrIsBrokenSymlink()` will return true even if
     * the symlink exists but its target does not. */
    static existsOrIsSymlink(linkPath) {
        try {
            FileSystem.getLinkStatistics(linkPath);
            return true;
        }
        catch (err) {
            return false;
        }
    }
    static _convertCommandAndArgsToShell(options, isWindows = IS_WINDOWS) {
        let shellCommand;
        let commandFlags;
        if (isWindows) {
            shellCommand = process.env.comspec || 'cmd';
            commandFlags = ['/d', '/s', '/c'];
        }
        else {
            shellCommand = 'sh';
            commandFlags = ['-c'];
        }
        let commandToRun;
        if (typeof options === 'string') {
            commandToRun = options;
        }
        else {
            const { command, args } = options;
            const normalizedCommand = escapeArgumentIfNeeded(command, isWindows);
            const normalizedArgs = [];
            for (const arg of args) {
                normalizedArgs.push(escapeArgumentIfNeeded(arg, isWindows));
            }
            commandToRun = [normalizedCommand, ...normalizedArgs].join(' ');
        }
        return {
            command: shellCommand,
            args: [...commandFlags, commandToRun]
        };
    }
    static _executeLifecycleCommandInternal(commandAndArgs, spawnFunction, options) {
        const { initCwd, initialEnvironment, environmentPathOptions, rushConfiguration, workingDirectory, handleOutput, ipc, connectSubprocessTerminator } = options;
        const environment = Utilities._createEnvironmentForRushCommand({
            initCwd,
            initialEnvironment,
            pathOptions: {
                ...environmentPathOptions,
                rushJsonFolder: rushConfiguration === null || rushConfiguration === void 0 ? void 0 : rushConfiguration.rushJsonFolder,
                projectRoot: workingDirectory,
                commonTempFolder: rushConfiguration ? rushConfiguration.commonTempFolder : undefined
            }
        });
        const stdio = handleOutput ? ['ignore', 'pipe', 'pipe'] : [0, 1, 2];
        if (ipc) {
            stdio.push('ipc');
        }
        const spawnOptions = {
            cwd: workingDirectory,
            env: environment,
            stdio
        };
        if (connectSubprocessTerminator) {
            Object.assign(spawnOptions, SubprocessTerminator.RECOMMENDED_OPTIONS);
        }
        const { command, args } = Utilities._convertCommandAndArgsToShell(commandAndArgs);
        if (IS_WINDOWS) {
            const shellCommand = [command, ...args].join(' ');
            return spawnFunction(shellCommand, [], { ...spawnOptions, shell: true });
        }
        else {
            return spawnFunction(command, args, spawnOptions);
        }
    }
    /**
     * Returns a process.env environment suitable for executing lifecycle scripts.
     * @param initialEnvironment - an existing environment to copy instead of process.env
     *
     * @remarks
     * Rush._assignRushInvokedFolder() assigns the `RUSH_INVOKED_FOLDER` variable globally
     * via the parent process's environment.
     */
    static _createEnvironmentForRushCommand(options) {
        var _a;
        if (options.initialEnvironment === undefined) {
            options.initialEnvironment = process.env;
        }
        // Set some defaults for the environment
        const environment = {};
        if ((_a = options.pathOptions) === null || _a === void 0 ? void 0 : _a.rushJsonFolder) {
            environment.RUSHSTACK_FILE_ERROR_BASE_FOLDER = options.pathOptions.rushJsonFolder;
        }
        for (const key of Object.getOwnPropertyNames(options.initialEnvironment)) {
            const normalizedKey = IS_WINDOWS ? key.toUpperCase() : key;
            // If Rush itself was invoked inside a lifecycle script, this may be set and would interfere
            // with Rush's installations.  If we actually want it, we will set it explicitly below.
            if (normalizedKey === 'INIT_CWD') {
                continue;
            }
            // When NPM invokes a lifecycle event, it copies its entire configuration into environment
            // variables.  Rush is supposed to be a deterministic controlled environment, so don't bring
            // this along.
            //
            // NOTE: Longer term we should clean out the entire environment and use rush.json to bring
            // back specific environment variables that the repo maintainer has determined to be safe.
            if (normalizedKey.match(/^NPM_CONFIG_/)) {
                continue;
            }
            // Use the uppercased environment variable name on Windows because environment variable names
            // are case-insensitive on Windows
            environment[normalizedKey] = options.initialEnvironment[key];
        }
        // When NPM invokes a lifecycle script, it sets an environment variable INIT_CWD that remembers
        // the directory that NPM started in.  This allows naive scripts to change their current working directory
        // and invoke NPM operations, while still be able to find a local .npmrc file.  Although Rush recommends
        // for toolchain scripts to be professionally written (versus brittle stuff like
        // "cd ./lib && npm run tsc && cd .."), we support INIT_CWD for compatibility.
        //
        // More about this feature: https://github.com/npm/npm/pull/12356
        if (options.initCwd) {
            environment['INIT_CWD'] = options.initCwd; // eslint-disable-line dot-notation
        }
        if (options.pathOptions) {
            if (options.pathOptions.includeRepoBin && options.pathOptions.commonTempFolder) {
                environment.PATH = Utilities._prependNodeModulesBinToPath(environment.PATH, options.pathOptions.commonTempFolder);
            }
            if (options.pathOptions.includeProjectBin && options.pathOptions.projectRoot) {
                environment.PATH = Utilities._prependNodeModulesBinToPath(environment.PATH, options.pathOptions.projectRoot);
            }
            if (options.pathOptions.additionalPathFolders) {
                environment.PATH = [...options.pathOptions.additionalPathFolders, environment.PATH].join(path.delimiter);
            }
        }
        // Communicate to downstream calls that they should not try to run hooks
        environment[EnvironmentVariableNames._RUSH_RECURSIVE_RUSHX_CALL] = '1';
        return environment;
    }
    /**
     * Prepend the node_modules/.bin folder under the specified folder to the specified PATH variable. For example,
     * if `rootDirectory` is "/foobar" and `existingPath` is "/bin", this function will return
     * "/foobar/node_modules/.bin:/bin"
     */
    static _prependNodeModulesBinToPath(existingPath, rootDirectory) {
        const binPath = path.resolve(rootDirectory, 'node_modules', '.bin');
        if (existingPath) {
            return `${binPath}${path.delimiter}${existingPath}`;
        }
        else {
            return binPath;
        }
    }
    static async _executeCommandInternalAsync({ command, args, workingDirectory, stdio, environment, keepEnvironment, onStdoutStreamChunk, captureOutput, captureExitCodeAndSignal }) {
        var _a;
        const spawnOptions = {
            cwd: workingDirectory,
            shell: true,
            stdio: stdio,
            env: keepEnvironment
                ? environment
                : Utilities._createEnvironmentForRushCommand({ initialEnvironment: environment }),
            maxBuffer: 10 * 1024 * 1024 // Set default max buffer size to 10MB
        };
        // This is needed since we specify shell=true below.
        // NOTE: On Windows if we escape "NPM", the spawnSync() function runs something like this:
        //   [ 'C:\\Windows\\system32\\cmd.exe', '/s', '/c', '""NPM" "install""' ]
        //
        // Due to a bug with Windows cmd.exe, the npm.cmd batch file's "%~dp0" variable will
        // return the current working directory instead of the batch file's directory.
        // The workaround is to not escape, npm, i.e. do this instead:
        //   [ 'C:\\Windows\\system32\\cmd.exe', '/s', '/c', '"npm "install""' ]
        //
        // We will come up with a better solution for this when we promote executeCommand()
        // into node-core-library, but for now this hack will unblock people:
        // Only escape the command if it actually contains spaces:
        const escapedCommand = escapeArgumentIfNeeded(command);
        const escapedArgs = args.map((x) => escapeArgumentIfNeeded(x));
        const shellCommand = [escapedCommand, ...escapedArgs].join(' ');
        const childProcess = child_process.spawn(shellCommand, spawnOptions);
        if (onStdoutStreamChunk) {
            const inspectStream = new Transform({
                transform: onStdoutStreamChunk
                    ? (chunk, encoding, callback) => {
                        const chunkString = chunk.toString();
                        const updatedChunk = onStdoutStreamChunk(chunkString);
                        callback(undefined, updatedChunk !== null && updatedChunk !== void 0 ? updatedChunk : chunk);
                    }
                    : undefined
            });
            (_a = childProcess.stdout) === null || _a === void 0 ? void 0 : _a.pipe(inspectStream).pipe(process.stdout);
        }
        return await Executable.waitForExitAsync(childProcess, {
            encoding: captureOutput ? 'utf8' : undefined,
            throwOnNonZeroExitCode: !captureExitCodeAndSignal,
            throwOnSignal: !captureExitCodeAndSignal
        });
    }
    static _processResult({ error, stderr, status }) {
        if (error) {
            error.message += `\n${stderr}`;
            if (status) {
                error.message += `\nExited with status ${status}`;
            }
            throw error;
        }
        if (status) {
            throw new Error(`The command failed with exit code ${status}\n${stderr}`);
        }
    }
}
Utilities.syncNpmrc = syncNpmrc;
//# sourceMappingURL=Utilities.js.map