diff --git a/scripts/build-cli-bundles.ts b/scripts/build-cli-bundles.ts index 06c3f4679..f42516aa0 100644 --- a/scripts/build-cli-bundles.ts +++ b/scripts/build-cli-bundles.ts @@ -7,10 +7,11 @@ At the time of writing (~2025-05-31), the bundles are created using Bun as the r When node stabilizes SEA (https://nodejs.org/api/single-executable-applications.html) [and supports ESM -.-], this code can be adapted to build it using that instead (but cross-platform will be a CI experience) */ +import { readFileSync } from 'node:fs'; import { readFile, rm, writeFile } from 'node:fs/promises'; import { basename } from 'node:path'; -import { $, fileURLToPath } from 'bun'; +import { $, type Build, build, fileURLToPath } from 'bun'; import { version } from '../package.json' with { type: 'json' }; @@ -30,13 +31,11 @@ const targets = (() => { 'bun-darwin-arm64-baseline', 'bun-linux-x64-musl', 'bun-linux-arm64-musl', - 'bun-linux-x64-musl-baseline', - 'bun-linux-arm64-musl-baseline', - ]; + ] satisfies Build.CompileTarget[]; } if (process.platform === 'win32') { - return ['bun-windows-x64', 'bun-windows-x64-baseline']; + return ['bun-windows-x64', 'bun-windows-x64-baseline'] satisfies Build.CompileTarget[]; } return [ @@ -50,9 +49,7 @@ const targets = (() => { 'bun-darwin-arm64-baseline', 'bun-linux-x64-musl', 'bun-linux-arm64-musl', - 'bun-linux-x64-musl-baseline', - 'bun-linux-arm64-musl-baseline', - ]; + ] satisfies Build.CompileTarget[]; })(); const entryPoints = [ @@ -77,6 +74,35 @@ await writeFile(metadataFile, newContent); for (const entryPoint of entryPoints) { const cliName = basename(entryPoint, '.ts'); + const lines = readFileSync(entryPoint, 'utf-8').split('\n'); + lines.splice(1, 0, 'import "proxy-agent";'); + + // Step 1: create one fat JS file with node resolver to ensure no imports point to non-node export conditions + const result = await build({ + entrypoints: [entryPoint], + files: { + [entryPoint]: lines.join('\n'), + }, + outdir: fileURLToPath(new URL(`../bundles/fat-clis`, import.meta.url)), + conditions: 'node', + target: 'bun', + sourcemap: 'none', + }); + + const entrypointResultFilePath = result.outputs[0]!.path; + + // Fix apify client js (it now lazy loads proxy-agent, which makes bun skip it from the bundle) + { + const entrypointResultFileContent = await result.outputs[0]!.text(); + + const newEntrypointResultFileContent = entrypointResultFileContent.replace( + `(0, utils_1.dynamicNodeImport)("proxy-agent")`, + `Promise.resolve().then(() => import_proxy_agent)`, + ); + + await writeFile(entrypointResultFilePath, newEntrypointResultFileContent); + } + for (const target of targets) { // eslint-disable-next-line prefer-const -- somehow it cannot tell that os and arch cannot be "const" while the rest are let let [, os, arch, musl, baseline] = target.split('-'); @@ -88,6 +114,7 @@ for (const entryPoint of entryPoints) { // If we are building on Windows ARM64, even though the target is x64, we mark it as "arm64" (there are some weird errors when compiling on x64 // and running on arm64). Hopefully bun will get arm64 native builds + // TODO: Vlad remove this in a subsequent PR as Bun now has native arm64 windows builds if (os === 'windows' && process.platform === 'win32') { const systemType = await $`pwsh -c "(Get-CimInstance Win32_ComputerSystem).SystemType"`.text(); @@ -108,8 +135,21 @@ for (const entryPoint of entryPoints) { const outFile = fileURLToPath(new URL(`../bundles/${fileName}`, import.meta.url)); console.log(`Building ${cliName} for ${target} (result: ${fileName})...`); - // TODO: --sourcemap crashes for w/e reason and --bytecode doesn't support ESM (TLA to be exact) - await $`bun build --compile --minify --target=${target} --outfile=${outFile} ${entryPoint}`; + + // Step 2: create the final executable bundle + await build({ + entrypoints: [entrypointResultFilePath], + compile: { + outfile: outFile, + target, + }, + format: 'esm', + minify: { + identifiers: true, + keepNames: true, + }, + bytecode: true, + }); // Remove the arch override await writeFile(metadataFile, newContent); diff --git a/scripts/install/dev-test-install.sh b/scripts/install/dev-test-install.sh new file mode 100644 index 000000000..aba0b6028 --- /dev/null +++ b/scripts/install/dev-test-install.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +# This script should be used like so: `/bin/cat dev-test-install.sh | bash` + +# Reset +Color_Off='' + +# Regular Colors +Red='' +Dim='' # White + +if [[ -t 1 ]]; then + # Reset + Color_Off='\033[0m' # Text Reset + + # Regular Colors + Red='\033[0;31m' # Red + Dim='\033[0;2m' # White +fi + +error() { + echo -e "${Red}error${Color_Off}:" "$@" >&2 + exit 1 +} + +info() { + echo -e "${Dim}$@ ${Color_Off}" +} + +platform=$(uname -ms) + +case $platform in +'Darwin x86_64') + target=darwin-x64 + ;; +'Darwin arm64') + target=darwin-arm64 + ;; +'Linux aarch64' | 'Linux arm64') + target=linux-arm64 + ;; +'MINGW64'*) + target=windows-x64 + ;; +'Linux x86_64' | *) + target=linux-x64 + ;; +esac + +case "$target" in +'linux'*) + if [ -f /etc/alpine-release ]; then + target="$target-musl" + fi + ;; +esac + +# If AVX2 isn't supported, use the -baseline build +case "$target" in +'darwin-x64'*) + if [[ $(sysctl -a | grep machdep.cpu | grep AVX2) == '' ]]; then + target="$target-baseline" + fi + ;; +'linux-x64'*) + # If AVX2 isn't supported, use the -baseline build + if [[ $(cat /proc/cpuinfo | grep avx2) = '' ]]; then + target="$target-baseline" + fi + ;; +esac + +install_env=APIFY_CLI_INSTALL +install_dir=${!install_env:-$HOME/.apify} +bin_dir=$install_dir/bin + +if [[ ! -d $bin_dir ]]; then + mkdir -p "$bin_dir" || + error "Failed to create install directory \"$bin_dir\"" +fi + +# Ensure we are in the apify-cli root by checking for ./package.json +if [[ ! -f ./package.json ]]; then + error "Not in the apify-cli root" +fi + +echo "Install directory: $install_dir" +echo "Bin directory: $bin_dir" + +# Ensure we have bun installed +if ! command -v bun &> /dev/null; then + error "bun could not be found. Please install it from https://bun.sh/docs/installation" + exit 1 +fi + +# Check package.json for the version +version=$(jq -r '.version' package.json) +echo "Version: $version" + +info "Installing dependencies" +yarn + +info "Building bundles" +yarn insert-cli-metadata && yarn build-bundles && git checkout -- src/lib/hooks/useCLIMetadata.ts + +info "Installing bundles" + +executable_names=("apify" "actor") + +for executable_name in "${executable_names[@]}"; do + output_filename="${executable_name}" + + info "Installing $executable_name bundle for version $version and target $target" + + cp "bundles/$executable_name-$version-$target" "$bin_dir/$output_filename" + chmod +x "$bin_dir/$output_filename" +done + +if ! [ -t 0 ] && [ -r /dev/tty ]; then + PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" APIFY_OPEN_TTY=1 "$bin_dir/apify" install +else + PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" "$bin_dir/apify" install +fi diff --git a/scripts/install/install.sh b/scripts/install/install.sh index 6bc5785ab..750406de7 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -19,25 +19,15 @@ Color_Off='' # Regular Colors Red='' -Green='' Dim='' # White -# Bold -Bold_White='' -Bold_Green='' - if [[ -t 1 ]]; then # Reset Color_Off='\033[0m' # Text Reset # Regular Colors Red='\033[0;31m' # Red - Green='\033[0;32m' # Green Dim='\033[0;2m' # White - - # Bold - Bold_Green='\033[1;32m' # Bold Green - Bold_White='\033[1m' # Bold White fi error() { @@ -49,14 +39,6 @@ info() { echo -e "${Dim}$@ ${Color_Off}" } -info_bold() { - echo -e "${Bold_White}$@ ${Color_Off}" -} - -success() { - echo -e "${Green}$@ ${Color_Off}" -} - if [[ $# -gt 1 ]]; then error 'Too many arguments, only 1 is allowed. The first can be a specific tag of Apify CLI to install. (e.g. "0.28.0")' fi @@ -101,10 +83,6 @@ if [[ $target = darwin-x64 ]]; then fi fi -GITHUB=${GITHUB-"https://github.com"} - -github_repo="$GITHUB/apify/apify-cli" - # If AVX2 isn't supported, use the -baseline build case "$target" in 'darwin-x64'*) @@ -190,20 +168,12 @@ for executable_name in "${executable_names[@]}"; do fi done -tildify() { - if [[ $1 = $HOME/* ]]; then - local replacement=\~/ - - echo "${1/$HOME\//$replacement}" - else - echo "$1" - fi -} - -echo '' -echo '' -success "Apify and Actor CLI $version were installed successfully!" -info "The binaries are located at $Bold_Green$(tildify "$bin_dir/apify") ${Dim}and $Bold_Green$(tildify "$bin_dir/actor")" - # Invoke the CLI to handle shell integrations nicely -PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" "$bin_dir/apify" install +# When running the script via `curl xxx | bash`, stdin is the script that gets consumed by bash. +# If stdin is not a tty and we have a readable /dev/tty, tell Node.js to open /dev/tty itself +# (shell-level redirects don't support raw mode properly for Node.js/Inquirer). +if ! [ -t 0 ] && [ -r /dev/tty ]; then + PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" APIFY_OPEN_TTY=1 "$bin_dir/apify" install +else + PROVIDED_INSTALL_DIR="$install_dir" FINAL_BIN_DIR="$bin_dir" "$bin_dir/apify" install +fi diff --git a/src/commands/cli-management/install.ts b/src/commands/cli-management/install.ts index ab981afd6..964873e27 100644 --- a/src/commands/cli-management/install.ts +++ b/src/commands/cli-management/install.ts @@ -1,18 +1,22 @@ import assert from 'node:assert'; -import { existsSync } from 'node:fs'; +import { existsSync, openSync } from 'node:fs'; import { mkdir, readFile, symlink, unlink, writeFile } from 'node:fs/promises'; -import { basename, join } from 'node:path'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { ReadStream } from 'node:tty'; import chalk from 'chalk'; +import which from 'which'; import { ApifyCommand } from '../../lib/command-framework/apify-command.js'; import { useCLIMetadata } from '../../lib/hooks/useCLIMetadata.js'; import { useYesNoConfirm } from '../../lib/hooks/user-confirmations/useYesNoConfirm.js'; -import { info, simpleLog, success, warning } from '../../lib/outputs.js'; -import { tildify } from '../../lib/utils.js'; +import { error, info, simpleLog, success, warning } from '../../lib/outputs.js'; +import { detectShell, shellConfigFile, tildify } from '../../lib/utils.js'; import { cliDebugPrint } from '../../lib/utils/cliDebugPrint.js'; const pathToInstallMarker = (installPath: string) => join(installPath, '.install-marker'); +const HOMEDIR = () => process.env.HOME ?? homedir(); export class InstallCommand extends ApifyCommand { static override name = 'install' as const; @@ -38,26 +42,50 @@ export class InstallCommand extends ApifyCommand { return; } - await this.symlinkToLocalBin(installPath); + // We don't want any errors bubbled up to prevent the command from finalizing + try { + await this.promptAddToShell(); + } catch (err: any) { + error({ message: err.message || 'Failed to automatically handle shell integration' }); + } - await this.promptAddToShell(); + simpleLog({ message: '' }); + + await this.symlinkToLocalBin(installPath); await writeFile(installMarkerPath, version); cliDebugPrint('[install] install marker written to', installMarkerPath); + simpleLog({ + message: [ + '', + chalk.green('Apify and Actor CLI were installed successfully!'), + '', + chalk.gray(` Version: ${chalk.green(version)}`), + chalk.gray( + ` Location: ${chalk.bold.white(tildify(join(installPath, 'apify')))} and ${chalk.bold.white(tildify(join(installPath, 'actor')))}`, + ), + ].join('\n'), + }); + simpleLog({ message: '' }); success({ message: 'To get started, run:' }); simpleLog({ message: chalk.white.bold(' apify --help\n actor --help') }); } private async symlinkToLocalBin(installPath: string) { - const userHomeDirectory = process.env.HOME; + // On windows our install script automatically handles path handling + if (process.platform === 'win32') { + return; + } - cliDebugPrint('[install] user home directory', userHomeDirectory); + const userHomeDirectory = HOMEDIR(); + + cliDebugPrint('[install -> symlinkToLocalBin] user home directory', userHomeDirectory); if (!userHomeDirectory) { - cliDebugPrint('[install] user home directory not found'); + cliDebugPrint('[install -> symlinkToLocalBin] user home directory not found'); warning({ message: chalk.gray(`User home directory not found, cannot symlink to ~/.local/bin`) }); @@ -67,9 +95,7 @@ export class InstallCommand extends ApifyCommand { const localBinDirectory = join(userHomeDirectory, '.local', 'bin'); // Make sure the directory exists - if (!existsSync(localBinDirectory)) { - await mkdir(localBinDirectory, { recursive: true }); - } + await mkdir(localBinDirectory, { recursive: true }); const fileNames = ['apify', 'actor', 'apify-cli']; @@ -94,103 +120,239 @@ export class InstallCommand extends ApifyCommand { cliDebugPrint('[install] symlink created for item', file, symlinkPath); } - info({ message: chalk.gray(`Symlinked apify, actor, and apify-cli to ${localBinDirectory}`) }); + info({ message: chalk.gray(`Symlinked apify, actor, and apify-cli to ${tildify(localBinDirectory)}`) }); + } + + /** + * Prompt using /dev/tty directly, bypassing Inquirer (whose internal readline + * cannot be closed and hangs the process when given a custom input stream). + */ + private async confirmFromTty(message: string): Promise { + let fd: number | undefined; + let ttyStream: ReadStream | undefined; + + const prompt = `${chalk.green('?')} ${chalk.bold(message)} ${chalk.dim('(Y/n)')} `; + + const writeDone = (answer: string) => { + // Clear the current line and rewrite with the final answer, like Inquirer does + process.stdout.write(`\r\x1b[2K${chalk.green('?')} ${chalk.bold(message)} ${chalk.cyan(answer)}\n`); + }; + + try { + cliDebugPrint('[install] opening /dev/tty for raw mode'); + fd = openSync('/dev/tty', 'r'); + ttyStream = new ReadStream(fd); + + process.stdout.write(prompt); + + ttyStream.setRawMode(true); + ttyStream.resume(); + + const result = await new Promise((resolve) => { + const onData = (data: Buffer) => { + const key = data.toString(); + + if (key === 'y' || key === 'Y' || key === '\r' || key === '\n') { + ttyStream!.removeListener('data', onData); + writeDone('Yes'); + resolve(true); + } else if (key === 'n' || key === 'N') { + ttyStream!.removeListener('data', onData); + writeDone('No'); + resolve(false); + } else if (key === '\u0003' || key === '\u0004') { + // Ctrl+C or Ctrl+D + ttyStream!.removeListener('data', onData); + process.stdout.write('\n'); + resolve(false); + } + }; + + ttyStream!.on('data', onData); + }); + + return result; + } catch (err) { + cliDebugPrint('[install] failed to open /dev/tty for raw mode', err); + return false; + } finally { + if (ttyStream) { + ttyStream.setRawMode(false); + ttyStream.pause(); + ttyStream.destroy(); + } + + // Keeping this code here if we will need it again, + // but it looks like in Bun it automatically closes the file descriptor [possibly ttyStream.destroy() does this] + + // if (fd !== undefined) { + // try { + // closeSync(fd); + // } catch { + // // Like in C, if close fails, tough luck + // } + // } + } } private async promptAddToShell() { - const installDir = process.env.PROVIDED_INSTALL_DIR; + // On windows our install script automatically handles path handling + if (process.platform === 'win32') { + return; + } + + // Check if we can already resolve the CLI from PATH + const [apifyCliPath, actorCliPath] = await Promise.allSettled([ + which('apify', { nothrow: true }), + which('actor', { nothrow: true }), + ]); + + if ( + apifyCliPath.status === 'fulfilled' && + actorCliPath.status === 'fulfilled' && + apifyCliPath.value && + actorCliPath.value + ) { + cliDebugPrint('[install -> promptAddToShell] already in PATH', { apifyCliPath, actorCliPath }); + + info({ message: chalk.gray(`Apify and Actor CLIs are already in PATH, skipping shell integration`) }); + return; + } + + const userHomeDirectory = HOMEDIR(); + + cliDebugPrint('[install -> promptAddToShell] user home directory', userHomeDirectory); + + const defaultInstallDir = process.env.APIFY_CLI_INSTALL ?? join(userHomeDirectory, '.apify'); + const defaultBinDir = process.env.FINAL_BIN_DIR ?? join(defaultInstallDir, 'bin'); + const installDir = process.env.PROVIDED_INSTALL_DIR ?? defaultInstallDir; if (!installDir) { warning({ message: chalk.gray(`Install directory not found, cannot add to shell`) }); return; } - const binDir = process.env.FINAL_BIN_DIR!; + const binDir = process.env.FINAL_BIN_DIR ?? defaultBinDir; simpleLog({ message: '' }); - const allowedToAutomaticallyDo = await useYesNoConfirm({ - message: - 'Should the CLI handle adding itself to your shell automatically? (If you say no, you will receive the lines to add to your shell config file)', - // For now, no stdin -> always false - providedConfirmFromStdin: false, - }); + const confirmMessage = 'Should the CLI handle adding itself to your shell automatically?'; + + let allowedToAutomaticallyDo: boolean; + + if (process.env.APIFY_OPEN_TTY) { + // When running via `curl | bash`, Inquirer's readline interface cannot be + // properly cleaned up (it never closes), which hangs the process. + // Instead, we open /dev/tty directly and read a single keypress ourselves. + allowedToAutomaticallyDo = await this.confirmFromTty(confirmMessage); + } else { + cliDebugPrint('[install] opening /dev/tty for raw mode not requested, falling back to normal flow'); + allowedToAutomaticallyDo = await useYesNoConfirm({ + message: confirmMessage, + // For now, no stdin -> always false + providedConfirmFromStdin: false, + }); + } - const shell = basename(process.env.SHELL ?? 'sh'); + const shell = detectShell(); + const configFile = shellConfigFile(userHomeDirectory, shell); const quotedInstallDir = `"${installDir.replaceAll('"', '\\"')}"`; const linesToAdd = []; - let configFile = ''; + let showOneLiner = true; switch (shell) { - case 'bash': { + case 'bash': + case 'zsh': { linesToAdd.push(`export APIFY_CLI_INSTALL=${quotedInstallDir}`); linesToAdd.push(`export PATH="$APIFY_CLI_INSTALL/bin:$PATH"`); - const configFiles = [join(process.env.HOME!, '.bashrc'), join(process.env.HOME!, '.bash_profile')]; - - if (process.env.XDG_CONFIG_HOME) { - configFiles.push( - join(process.env.XDG_CONFIG_HOME, '.bashrc'), - join(process.env.XDG_CONFIG_HOME, '.bash_profile'), - join(process.env.XDG_CONFIG_HOME, 'bashrc'), - join(process.env.XDG_CONFIG_HOME, 'bash_profile'), - ); - } - - // Find the first likely match for the config file [because bash loves having a lot of alternatives] - for (const maybeConfigFile of configFiles) { - if (existsSync(maybeConfigFile)) { - configFile = maybeConfigFile; - break; - } - } - break; } - case 'zsh': - linesToAdd.push(`export APIFY_CLI_INSTALL=${quotedInstallDir}`); - linesToAdd.push(`export PATH="$APIFY_CLI_INSTALL/bin:$PATH"`); - - configFile = join(process.env.HOME!, '.zshrc'); - - break; case 'fish': { linesToAdd.push(`set --export APIFY_CLI_INSTALL ${quotedInstallDir}`); linesToAdd.push(`set --export PATH ${binDir} $PATH`); - configFile = join(process.env.HOME!, '.config', 'fish', 'config.fish'); break; } - default: + default: { + // We don't know the shell, so we just show it to the user linesToAdd.push(`export APIFY_CLI_INSTALL=${quotedInstallDir}`); linesToAdd.push(`export PATH="$APIFY_CLI_INSTALL/bin:$PATH"`); - // We don't use a path as we don't know the shell - configFile = '~/.bashrc'; + // Never automatically add to the file as we don't know the shell + allowedToAutomaticallyDo = false; + // And don't show the one-liner because we don't know the shell + showOneLiner = false; break; + } } simpleLog({ message: '' }); if (allowedToAutomaticallyDo && configFile) { - const oldContent = await readFile(configFile, 'utf-8'); + const oldContent = await readFile(configFile, 'utf-8').catch((err) => { + if (err.code === 'ENOENT') { + // File doesn't exist, that's fine + return ''; + } - const newContent = `${oldContent}\n\n# apify cli\n${linesToAdd.join('\n')}`; + throw new Error( + `Failed to read config file "${tildify(configFile)}". Received error code: ${err.code}; ${err.message}`, + ); + }); - await writeFile(configFile, newContent); + const newContent = `${oldContent}\n\n# apify cli\n${linesToAdd.join('\n')}\n`; + + try { + await writeFile(configFile, newContent); + } catch (err: any) { + if (err.code === 'EACCES') { + throw new Error( + `Failed to write to config file "${tildify(configFile)}", as the CLI does not have permission to write to it.`, + ); + } + + throw new Error( + `Failed to write to config file "${tildify(configFile)}". Received error code: ${err.code}; ${err.message}`, + ); + } - info({ message: chalk.gray(`Added "${tildify(binDir)}" to $PATH in ${tildify(configFile)}`) }); - } else { info({ message: [ + chalk.gray(`Added "${tildify(binDir)}" to your PATH in ${tildify(configFile)}.`), chalk.gray( - `Manually add the following lines to your shell config file (${tildify(configFile)} or similar):`, + ` You may need to run ${chalk.white.bold(`source ${tildify(configFile)}`)} to reload your shell.`, ), - ...linesToAdd.map((line) => chalk.white.bold(` ${line}`)), ].join('\n'), }); + + return; } + + const resolvedConfigFile = configFile ?? 'your shell config file'; + + if (showOneLiner) { + const oneLiner = `echo -e '${linesToAdd.join('\\n')}' >> "${resolvedConfigFile}" && source "${resolvedConfigFile}"`; + + info({ + message: [ + // + chalk.gray(`The Apify & Actor CLIs are not in your PATH. Run:`), + '', + chalk.white.bold(` ${oneLiner}`), + ].join('\n'), + }); + + return; + } + + info({ + message: [ + chalk.gray(`Manually add the following lines to ${resolvedConfigFile} or similar:`), + ...linesToAdd.map((line) => chalk.white.bold(` ${line}`)), + ].join('\n'), + }); } } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index e5425426e..088a9d290 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -48,6 +48,7 @@ import { } from './consts.js'; import { deleteFile, ensureFolderExistsSync, rimrafPromised } from './files.js'; import type { AuthJSON } from './types.js'; +import { cliDebugPrint } from './utils/cliDebugPrint.js'; // Export AJV properly: https://github.com/ajv-validator/ajv/issues/2132 // Welcome to the state of JavaScript/TypeScript and CJS/ESM interop. @@ -174,7 +175,8 @@ export async function getLoggedClient(token?: string, apiBaseUrl?: string) { let userInfo; try { userInfo = await apifyClient.user('me').get(); - } catch { + } catch (err) { + cliDebugPrint('[getLoggedClient] error getting user info', { error: err, apiBaseUrl }); return null; } @@ -711,3 +713,57 @@ export const tildify = (path: string) => { return path; }; + +export function detectShell() { + const shell = process.env.SHELL ?? ''; + + if (shell.includes('zsh')) { + return 'zsh'; + } + + if (shell.includes('bash')) { + return 'bash'; + } + + if (shell.includes('fish')) { + return 'fish'; + } + + return 'unknown'; +} + +export function shellConfigFile(userHomeDirectory: string, shell: ReturnType): string | null { + // eslint-disable-next-line default-case -- We do not want to add a shell and it fall through to default case + switch (shell) { + case 'bash': { + const configFiles = [join(userHomeDirectory, '.bashrc'), join(userHomeDirectory, '.bash_profile')]; + + if (process.env.XDG_CONFIG_HOME) { + configFiles.push( + join(process.env.XDG_CONFIG_HOME, '.bashrc'), + join(process.env.XDG_CONFIG_HOME, '.bash_profile'), + join(process.env.XDG_CONFIG_HOME, 'bashrc'), + join(process.env.XDG_CONFIG_HOME, 'bash_profile'), + ); + } + + for (const maybeConfigFile of configFiles) { + if (existsSync(maybeConfigFile)) { + return maybeConfigFile; + } + } + + return null; + } + case 'zsh': { + const zshBaseDir = process.env.ZDOTDIR || homedir(); + return join(zshBaseDir, '.zshrc'); + } + case 'fish': { + return join(userHomeDirectory, '.config', 'fish', 'config.fish'); + } + case 'unknown': { + return null; + } + } +}