diff --git a/README.md b/README.md index 1bc4858..b74d797 100644 --- a/README.md +++ b/README.md @@ -317,6 +317,24 @@ The base URL should point to a server that mirrors the structure of `https://mal - `/releases/npm.json` (JavaScript new packages list) - `/releases/pypi.json` (Python new packages list) +## Custom Install Directory + +By default, Safe Chain installs itself into `~/.safe-chain`. You can change this by passing an explicit install directory to the installer. This is useful for system-wide installations (e.g. inside a Docker image) or when you need to avoid conflicts with other tools. + +When set, all Safe Chain data (binary, shims, scripts, config) is placed under the custom directory instead of `~/.safe-chain`. + +### Unix/Linux/macOS + +```shell +curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --install-dir /usr/local/.safe-chain +``` + +### Windows + +```powershell +iex "& { $(iwr 'https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.ps1' -UseBasicParsing) } -InstallDir 'C:\ProgramData\safe-chain'" +``` + # Usage in CI/CD You can protect your CI/CD pipelines from malicious packages by integrating Aikido Safe Chain into your build process. This ensures that any packages installed during your automated builds are checked for malware before installation. @@ -407,6 +425,7 @@ pipeline { environment { // Jenkins does not automatically persist PATH updates from setup-ci, // so add the shims + binary directory explicitly for all stages. + // If you installed into a custom directory, replace ~/.safe-chain with that path here. PATH = "${env.HOME}/.safe-chain/shims:${env.HOME}/.safe-chain/bin:${env.PATH}" } @@ -462,7 +481,7 @@ To add safe-chain in GitLab pipelines, you need to install it in the image runni # Install safe-chain RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci - # Add safe-chain to PATH + # Add safe-chain to PATH (update paths if you used a custom install dir) ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}" ``` diff --git a/install-scripts/install-safe-chain.ps1 b/install-scripts/install-safe-chain.ps1 index ffe2505..a11edf6 100644 --- a/install-scripts/install-safe-chain.ps1 +++ b/install-scripts/install-safe-chain.ps1 @@ -4,11 +4,52 @@ param( [switch]$ci, - [switch]$includepython + [switch]$includepython, + [string]$InstallDir ) +# Validates and normalizes the requested install directory. +# Rejects non-absolute, root, PATH-like, and traversal-containing paths. +function Test-InstallDir { + param([string]$Dir) + + if ([string]::IsNullOrWhiteSpace($Dir)) { + return @{ Ok = $true; Normalized = $null } + } + + if (-not [System.IO.Path]::IsPathRooted($Dir)) { + return @{ Ok = $false; Reason = "-InstallDir must be an absolute path, got: $Dir" } + } + + if ($Dir.Contains([System.IO.Path]::PathSeparator)) { + return @{ Ok = $false; Reason = "-InstallDir must not contain the PATH separator ($([System.IO.Path]::PathSeparator))" } + } + + $inputSegments = $Dir.Split([char[]]@('\', '/'), [System.StringSplitOptions]::RemoveEmptyEntries) + if ($inputSegments -contains "..") { + return @{ Ok = $false; Reason = "-InstallDir must not contain path traversal segments" } + } + + $normalized = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetPathRoot($normalized) + if ($normalized.TrimEnd('\', '/') -eq $root.TrimEnd('\', '/')) { + return @{ Ok = $false; Reason = "-InstallDir cannot be a root or drive-root directory" } + } + + return @{ Ok = $true; Normalized = $normalized } +} + $Version = $env:SAFE_CHAIN_VERSION # Will be fetched from latest release if not set -$InstallDir = Join-Path $env:USERPROFILE ".safe-chain\bin" +$SafeChainBase = if ($InstallDir) { $InstallDir } else { Join-Path $HOME ".safe-chain" } + +$installDirValidation = Test-InstallDir -Dir $SafeChainBase +if (-not $installDirValidation.Ok) { + Write-Host "[ERROR] $($installDirValidation.Reason)" -ForegroundColor Red + exit 1 +} + +$SafeChainBase = $installDirValidation.Normalized +$InstallDir = Join-Path $SafeChainBase "bin" $RepoUrl = "https://github.com/AikidoSec/safe-chain" # Ensure TLS 1.2 is enabled for downloads @@ -98,6 +139,59 @@ function Get-Architecture { } } +# Emits the deprecation warning for SAFE_CHAIN_VERSION and prints the version-pinned install command. +# Returns immediately when no version was provided through the environment. +function Write-VersionDeprecationWarning { + if ([string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { + return + } + + Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." + Write-Warn "" + Write-Warn "Please use direct download URLs for version pinning instead:" + Write-Warn "" + if ($ci) { + Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" + } else { + Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" + } + Write-Warn "" +} + +# Builds the Windows release binary filename for the detected architecture. +# Centralizes binary name generation for the download step. +function Get-BinaryName { + param([string]$Architecture) + + return "safe-chain-win-$Architecture.exe" +} + +# Runs safe-chain setup or setup-ci after the binary is installed. +# Temporarily appends the install directory to PATH and downgrades setup failures to warnings. +function Invoke-SafeChainSetup { + param( + [string]$BinaryPath, + [string]$InstallDirectory + ) + + $setupCmd = if ($ci) { "setup-ci" } else { "setup" } + + Write-Info "Running safe-chain $setupCmd..." + try { + $env:Path = "$env:Path;$InstallDirectory" + & $BinaryPath $setupCmd + + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain was installed but setup encountered issues." + Write-Warn "You can run 'safe-chain $setupCmd' manually later." + } + } + catch { + Write-Warn "safe-chain was installed but setup encountered issues: $_" + Write-Warn "You can run 'safe-chain $setupCmd' manually later." + } +} + # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -149,19 +243,7 @@ function Remove-VoltaInstallation { # Main installation function Install-SafeChain { - # Show deprecation warning if SAFE_CHAIN_VERSION is set - if (-not [string]::IsNullOrWhiteSpace($env:SAFE_CHAIN_VERSION)) { - Write-Warn "SAFE_CHAIN_VERSION environment variable is deprecated." - Write-Warn "" - Write-Warn "Please use direct download URLs for version pinning instead:" - Write-Warn "" - if ($ci) { - Write-Warn " iex `"& { `$(iwr 'https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1' -UseBasicParsing) } -ci`"" - } else { - Write-Warn " iex (iwr `"https://github.com/AikidoSec/safe-chain/releases/download/$env:SAFE_CHAIN_VERSION/install-safe-chain.ps1`" -UseBasicParsing)" - } - Write-Warn "" - } + Write-VersionDeprecationWarning # Fetch latest version if VERSION is not set if ([string]::IsNullOrWhiteSpace($Version)) { @@ -192,7 +274,7 @@ function Install-SafeChain { # Detect platform $arch = Get-Architecture - $binaryName = "safe-chain-win-$arch.exe" + $binaryName = Get-BinaryName -Architecture $arch Write-Info "Detected architecture: $arch" @@ -238,31 +320,7 @@ function Install-SafeChain { Write-Info "Binary installed to: $finalFile" - # Build setup command based on parameters - $setupCmd = if ($ci) { "setup-ci" } else { "setup" } - $setupArgs = @() - - # Execute safe-chain setup - Write-Info "Running safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })..." - try { - $env:Path = "$env:Path;$InstallDir" - - if ($setupArgs) { - & $finalFile $setupCmd $setupArgs - } - else { - & $finalFile $setupCmd - } - - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain was installed but setup encountered issues." - Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later." - } - } - catch { - Write-Warn "safe-chain was installed but setup encountered issues: $_" - Write-Warn "You can run 'safe-chain $setupCmd $(if ($setupArgs) { $setupArgs -join ' ' })' manually later." - } + Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir } # Run installation diff --git a/install-scripts/install-safe-chain.sh b/install-scripts/install-safe-chain.sh index 182cdad..da7d3c0 100755 --- a/install-scripts/install-safe-chain.sh +++ b/install-scripts/install-safe-chain.sh @@ -6,9 +6,53 @@ set -e # Exit on error +# Validates a user-provided install dir and exits on unsafe values. +# Rejects relative paths, root paths, PATH separators, and traversal segments. +validate_install_dir() { + dir="$1" + + if [ -z "$dir" ]; then + return 0 + fi + + case "$dir" in + /*) ;; + *) + printf '[ERROR] --install-dir must be an absolute path, got: %s\n' "$dir" >&2 + exit 1 + ;; + esac + + case "$dir" in + *:*) + printf '[ERROR] --install-dir must not contain the PATH separator (:)\n' >&2 + exit 1 + ;; + esac + + if [ "$dir" = "/" ]; then + printf '[ERROR] --install-dir cannot be a root or drive-root directory\n' >&2 + exit 1 + fi + + old_ifs=$IFS + IFS='/' + set -- $dir + IFS=$old_ifs + + for segment in "$@"; do + if [ "$segment" = ".." ]; then + printf '[ERROR] --install-dir must not contain path traversal segments\n' >&2 + exit 1 + fi + done +} + # Configuration VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set -INSTALL_DIR="${HOME}/.safe-chain/bin" +SAFE_CHAIN_BASE="${HOME}/.safe-chain" + +INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" REPO_URL="https://github.com/AikidoSec/safe-chain" # Colors for output @@ -126,6 +170,75 @@ download() { fi } +# Prints the deprecation warning for SAFE_CHAIN_VERSION and the replacement install command. +# Returns immediately when no version was pinned through the environment. +warn_deprecated_version_env() { + if [ -z "$SAFE_CHAIN_VERSION" ]; then + return + fi + + warn "SAFE_CHAIN_VERSION environment variable is deprecated." + warn "" + warn "Please use direct download URLs for version pinning instead:" + warn "" + if [ "$USE_CI_SETUP" = "true" ]; then + warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" + else + warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" + fi + warn "" +} + +# Ensures VERSION is populated before installation continues. +# Fetches the latest release only when no explicit version was provided. +ensure_version() { + if [ -n "$VERSION" ]; then + return + fi + + info "Fetching latest release version..." + VERSION=$(fetch_latest_version) +} + +# Constructs platform-specific binary filename to match GitHub release asset naming convention. +get_binary_name() { + os="$1" + arch="$2" + + if [ "$os" = "win" ]; then + printf 'safe-chain-%s-%s.exe\n' "$os" "$arch" + else + printf 'safe-chain-%s-%s\n' "$os" "$arch" + fi +} + +# Returns the final installation path for the downloaded safe-chain binary. +# Uses INSTALL_DIR and the platform-specific executable name. +get_final_binary_path() { + os="$1" + + if [ "$os" = "win" ]; then + printf '%s/safe-chain.exe\n' "$INSTALL_DIR" + else + printf '%s/safe-chain\n' "$INSTALL_DIR" + fi +} + +run_setup_command() { + final_file="$1" + + setup_cmd="setup" + if [ "$USE_CI_SETUP" = "true" ]; then + setup_cmd="setup-ci" + fi + + info "Running safe-chain $setup_cmd..." + if ! "$final_file" "$setup_cmd"; then + warn "safe-chain was installed but setup encountered issues." + warn "You can run 'safe-chain $setup_cmd' manually later." + fi +} + # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -229,19 +342,39 @@ remove_nvm_installation() { # Parse command-line arguments parse_arguments() { - for arg in "$@"; do - case "$arg" in + while [ $# -gt 0 ]; do + case "$1" in --ci) USE_CI_SETUP=true ;; + --install-dir) + shift + if [ $# -eq 0 ]; then + error "Missing value for --install-dir" + fi + if [ -z "$1" ]; then + error "--install-dir must not be empty" + fi + SAFE_CHAIN_BASE="$1" + ;; + --install-dir=*) + SAFE_CHAIN_BASE="${1#--install-dir=}" + if [ -z "$SAFE_CHAIN_BASE" ]; then + error "--install-dir must not be empty" + fi + ;; --include-python) warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." ;; *) - error "Unknown argument: $arg" + error "Unknown argument: $1" ;; esac + shift done + + validate_install_dir "${SAFE_CHAIN_BASE}" + INSTALL_DIR="${SAFE_CHAIN_BASE}/bin" } # Main installation @@ -252,25 +385,9 @@ main() { # Parse command-line arguments parse_arguments "$@" - # Show deprecation warning if SAFE_CHAIN_VERSION is set - if [ -n "$SAFE_CHAIN_VERSION" ]; then - warn "SAFE_CHAIN_VERSION environment variable is deprecated." - warn "" - warn "Please use direct download URLs for version pinning instead:" - warn "" - if [ "$USE_CI_SETUP" = "true" ]; then - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh -s -- --ci" - else - warn " curl -fsSL https://github.com/AikidoSec/safe-chain/releases/download/${SAFE_CHAIN_VERSION}/install-safe-chain.sh | sh" - fi - warn "" - fi + warn_deprecated_version_env - # Fetch latest version if VERSION is not set - if [ -z "$VERSION" ]; then - info "Fetching latest release version..." - VERSION=$(fetch_latest_version) - fi + ensure_version # Check if the requested version is already installed if is_version_installed "$VERSION"; then @@ -294,11 +411,7 @@ main() { # Detect platform OS=$(detect_os) ARCH=$(detect_arch) - if [ "$OS" = "win" ]; then - BINARY_NAME="safe-chain-${OS}-${ARCH}.exe" - else - BINARY_NAME="safe-chain-${OS}-${ARCH}" - fi + BINARY_NAME=$(get_binary_name "$OS" "$ARCH") info "Detected platform: ${OS}-${ARCH}" @@ -316,11 +429,7 @@ main() { download "$DOWNLOAD_URL" "$TEMP_FILE" # Rename and make executable - if [ "$OS" = "win" ]; then - FINAL_FILE="${INSTALL_DIR}/safe-chain.exe" - else - FINAL_FILE="${INSTALL_DIR}/safe-chain" - fi + FINAL_FILE=$(get_final_binary_path "$OS") mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" if [ "$OS" != "win" ]; then chmod +x "$FINAL_FILE" || error "Failed to make binary executable" @@ -328,20 +437,7 @@ main() { info "Binary installed to: $FINAL_FILE" - # Build setup command based on arguments - SETUP_CMD="setup" - SETUP_ARGS="" - - if [ "$USE_CI_SETUP" = "true" ]; then - SETUP_CMD="setup-ci" - fi - - # Execute safe-chain setup - info "Running safe-chain $SETUP_CMD $SETUP_ARGS..." - if ! "$FINAL_FILE" $SETUP_CMD $SETUP_ARGS; then - warn "safe-chain was installed but setup encountered issues." - warn "You can run 'safe-chain $SETUP_CMD $SETUP_ARGS' manually later." - fi + run_setup_command "$FINAL_FILE" } main "$@" diff --git a/install-scripts/uninstall-safe-chain.ps1 b/install-scripts/uninstall-safe-chain.ps1 index 3292cdd..6e24d5d 100644 --- a/install-scripts/uninstall-safe-chain.ps1 +++ b/install-scripts/uninstall-safe-chain.ps1 @@ -4,8 +4,6 @@ # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } -$DotSafeChain = Join-Path $HomeDir ".safe-chain" -$InstallDir = Join-Path $DotSafeChain "bin" # Helper functions function Write-Info { @@ -24,6 +22,146 @@ function Write-Error-Custom { exit 1 } +# Derives the safe-chain base install directory from a resolved binary path. +# Rejects wrapper scripts and paths that do not match the packaged bin layout. +function Get-InstallDirFromBinaryPath { + param([string]$BinaryPath) + + if ([string]::IsNullOrWhiteSpace($BinaryPath)) { + return $null + } + + try { + $resolvedPath = (Resolve-Path -LiteralPath $BinaryPath -ErrorAction Stop).Path + } + catch { + $resolvedPath = [System.IO.Path]::GetFullPath($BinaryPath) + } + + $fileName = [System.IO.Path]::GetFileName($resolvedPath) + if (($fileName -ne "safe-chain") -and ($fileName -ne "safe-chain.exe")) { + return $null + } + + if ($resolvedPath -match '\.(js|cjs|mjs|cmd|ps1)$') { + return $null + } + + $binDir = Split-Path -Parent $resolvedPath + if ((Split-Path -Leaf $binDir) -ne "bin") { + return $null + } + + return (Split-Path -Parent $binDir) +} + +# Returns the first safe-chain command found on PATH, if any. +# Used as the starting point for install-dir discovery. +function Get-SafeChainCommand { + return Get-Command safe-chain -ErrorAction SilentlyContinue | Select-Object -First 1 +} + +# Returns the safe-chain command path only when it points to a valid packaged binary install. +# Prevents teardown from invoking arbitrary wrappers or scripts from PATH. +function Get-ValidatedSafeChainCommandPath { + $command = Get-SafeChainCommand + if (-not $command -or [string]::IsNullOrWhiteSpace($command.Path)) { + return $null + } + + $installDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path + if (-not $installDir) { + return $null + } + + return $command.Path +} + +# Invokes the validated safe-chain binary with get-install-dir and returns the reported base directory. +# Safely returns $null when the command is unavailable or the lookup fails. +function Get-ReportedInstallDir { + $safeChainPath = Get-ValidatedSafeChainCommandPath + if (-not $safeChainPath) { + return $null + } + + try { + $reportedInstallDir = & $safeChainPath get-install-dir 2>$null | Select-Object -First 1 + if ($reportedInstallDir) { + $reportedInstallDir = $reportedInstallDir.Trim() + } + if ($reportedInstallDir) { + return $reportedInstallDir + } + } + catch { + return $null + } + + return $null +} + +# Determines the safe-chain base install directory for uninstall. +# Prefers the binary-reported location, then derives it from PATH, then falls back to the default home-dir layout. +function Get-SafeChainInstallDir { + $reportedInstallDir = Get-ReportedInstallDir + if ($reportedInstallDir) { + return $reportedInstallDir + } + + $command = Get-SafeChainCommand + if ($command -and $command.Path) { + $discoveredInstallDir = Get-InstallDirFromBinaryPath -BinaryPath $command.Path + if ($discoveredInstallDir) { + return $discoveredInstallDir + } + } + + return (Join-Path $HomeDir ".safe-chain") +} + +# Finds the installed safe-chain binary inside the resolved install directory. +# Falls back to a validated safe-chain command when the expected file is missing. +function Find-SafeChainBinary { + param([string]$DotSafeChain) + + $safeChainExe = Join-Path $DotSafeChain "bin/safe-chain.exe" + $safeChainBin = Join-Path $DotSafeChain "bin/safe-chain" + + if (Test-Path $safeChainExe) { + return $safeChainExe + } + + if (Test-Path $safeChainBin) { + return $safeChainBin + } + + return Get-ValidatedSafeChainCommandPath +} + +# Runs safe-chain teardown before removing the installation directory. +# Converts teardown failures into warnings so uninstall can still complete. +function Invoke-SafeChainTeardown { + param([string]$SafeChainPath) + + if (-not $SafeChainPath) { + Write-Warn "safe-chain command not found. Proceeding with uninstallation." + return + } + + Write-Info "Running safe-chain teardown..." + try { + & $SafeChainPath teardown + if ($LASTEXITCODE -ne 0) { + Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." + } + } + catch { + Write-Warn "safe-chain teardown encountered issues: $_" + Write-Warn "Continuing with uninstallation..." + } +} + # Check and uninstall npm global package if present function Remove-NpmInstallation { # Check if npm is available @@ -76,49 +214,9 @@ function Remove-VoltaInstallation { # Main uninstallation function Uninstall-SafeChain { Write-Info "Uninstalling safe-chain..." - - # Run teardown if safe-chain is available - # Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms - $safeChainExe = Join-Path $InstallDir "safe-chain.exe" - $safeChainBin = Join-Path $InstallDir "safe-chain" - - $safeChainPath = $null - if (Test-Path $safeChainExe) { - $safeChainPath = $safeChainExe - } - elseif (Test-Path $safeChainBin) { - $safeChainPath = $safeChainBin - } - - if ($safeChainPath) { - Write-Info "Running safe-chain teardown..." - try { - & $safeChainPath teardown - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." - } - } - catch { - Write-Warn "safe-chain teardown encountered issues: $_" - Write-Warn "Continuing with uninstallation..." - } - } - elseif (Get-Command safe-chain -ErrorAction SilentlyContinue) { - Write-Info "Running safe-chain teardown..." - try { - safe-chain teardown - if ($LASTEXITCODE -ne 0) { - Write-Warn "safe-chain teardown encountered issues, continuing with uninstallation..." - } - } - catch { - Write-Warn "safe-chain teardown encountered issues: $_" - Write-Warn "Continuing with uninstallation..." - } - } - else { - Write-Warn "safe-chain command not found. Proceeding with uninstallation." - } + $DotSafeChain = Get-SafeChainInstallDir + $safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain + Invoke-SafeChainTeardown -SafeChainPath $safeChainPath # Remove npm and Volta installations Remove-NpmInstallation diff --git a/install-scripts/uninstall-safe-chain.sh b/install-scripts/uninstall-safe-chain.sh index dff6f31..d215405 100755 --- a/install-scripts/uninstall-safe-chain.sh +++ b/install-scripts/uninstall-safe-chain.sh @@ -7,7 +7,6 @@ set -e # Exit on error # Configuration -DOT_SAFE_CHAIN="${HOME}/.safe-chain" # Colors for output RED='\033[0;31m' @@ -34,6 +33,159 @@ command_exists() { command -v "$1" >/dev/null 2>&1 } +# Resolves a path to its canonical filesystem location when possible. +# Follows symlinks so binary validation can inspect the real installed path. +resolve_path() { + target="$1" + + while [ -L "$target" ]; do + link_target=$(readlink "$target" 2>/dev/null || echo "") + if [ -z "$link_target" ]; then + break + fi + + case "$link_target" in + /*) target="$link_target" ;; + *) + target="$(dirname "$target")/$link_target" + ;; + esac + done + + target_dir=$(dirname "$target") + target_name=$(basename "$target") + + if cd "$target_dir" 2>/dev/null; then + printf '%s/%s\n' "$(pwd -P)" "$target_name" + else + printf '%s\n' "$target" + fi +} + +# Derives the safe-chain base install directory from a packaged binary path. +# Rejects wrapper scripts and paths that do not match the expected bin layout. +derive_install_dir_from_binary() { + binary_path="$1" + + if [ -z "$binary_path" ]; then + return 1 + fi + + resolved_path=$(resolve_path "$binary_path") + binary_name=$(basename "$resolved_path") + case "$binary_name" in + safe-chain|safe-chain.exe) ;; + *) return 1 ;; + esac + + case "$resolved_path" in + *.js|*.cjs|*.mjs|*.cmd|*.ps1) return 1 ;; + esac + + binary_dir=$(dirname "$resolved_path") + if [ "$(basename "$binary_dir")" != "bin" ]; then + return 1 + fi + + dirname "$binary_dir" +} + +# Determines the installed safe-chain base directory for uninstall. +# Prefers the binary-reported location, then infers it from PATH, then falls back to ~/.safe-chain. +get_install_dir() { + reported_install_dir=$(get_reported_install_dir || true) + if [ -n "$reported_install_dir" ]; then + printf '%s\n' "$reported_install_dir" + return 0 + fi + + command_path=$(get_safe_chain_command_path || true) + install_dir=$(derive_install_dir_from_binary "$command_path" || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 + fi + + printf '%s\n' "${HOME}/.safe-chain" +} + +# Returns the current safe-chain command path from PATH. +# Fails when safe-chain is not currently resolvable. +get_safe_chain_command_path() { + if ! command_exists safe-chain; then + return 1 + fi + + command -v safe-chain +} + +# Returns the safe-chain command path only when it resolves to a valid packaged binary install. +# Prevents the uninstaller from invoking arbitrary PATH entries. +get_validated_safe_chain_command_path() { + command_path=$(get_safe_chain_command_path || true) + if [ -z "$command_path" ]; then + return 1 + fi + + install_dir=$(derive_install_dir_from_binary "$command_path" || true) + if [ -z "$install_dir" ]; then + return 1 + fi + + printf '%s\n' "$command_path" +} + +# Asks the validated safe-chain binary for its install directory via get-install-dir. +# Returns nothing if the command is unavailable or the lookup fails. +get_reported_install_dir() { + safe_chain_path=$(get_validated_safe_chain_command_path || true) + if [ -z "$safe_chain_path" ]; then + return 1 + fi + + install_dir=$("$safe_chain_path" get-install-dir 2>/dev/null || true) + if [ -n "$install_dir" ]; then + printf '%s\n' "$install_dir" + return 0 + fi + + return 1 +} + +# Locates the installed safe-chain binary to use for teardown. +# Checks the discovered install dir first, then falls back to a validated PATH entry. +find_installed_safe_chain_binary() { + dot_safe_chain="$1" + + safe_chain_location="$dot_safe_chain/bin/safe-chain" + if [ -x "$safe_chain_location" ]; then + printf '%s\n' "$safe_chain_location" + return 0 + fi + + command_path=$(get_validated_safe_chain_command_path || true) + if [ -n "$command_path" ]; then + printf '%s\n' "$command_path" + return 0 + fi + + return 1 +} + +# Runs safe-chain teardown before removing files. +# Continues with uninstall even if teardown is unavailable or fails. +run_safe_chain_teardown() { + safe_chain_command="$1" + + if [ -z "$safe_chain_command" ]; then + warn "safe-chain command not found. Proceeding with uninstallation." + return + fi + + info "Running safe-chain teardown..." + "$safe_chain_command" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." +} + # Check and uninstall npm global package if present remove_npm_installation() { if ! command_exists npm; then @@ -139,17 +291,9 @@ remove_nvm_installation() { # Main uninstallation main() { - SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" - - if [ -x "$SAFE_CHAIN_LOCATION" ]; then - info "Running safe-chain teardown..." - "$SAFE_CHAIN_LOCATION" teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." - elif command_exists safe-chain; then - info "Running safe-chain teardown..." - safe-chain teardown || warn "safe-chain teardown encountered issues, continuing with uninstallation..." - else - warn "safe-chain command not found. Proceeding with uninstallation." - fi + DOT_SAFE_CHAIN=$(get_install_dir) + SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true) + run_safe_chain_teardown "$SAFE_CHAIN_COMMAND" # Check for existing safe-chain installation through nvm, volta, or npm remove_npm_installation diff --git a/packages/safe-chain/bin/safe-chain.js b/packages/safe-chain/bin/safe-chain.js index 8d942e4..43819b9 100755 --- a/packages/safe-chain/bin/safe-chain.js +++ b/packages/safe-chain/bin/safe-chain.js @@ -16,6 +16,7 @@ import path from "path"; import { fileURLToPath } from "url"; import fs from "fs"; import { knownAikidoTools } from "../src/shell-integration/helpers.js"; +import { getInstalledSafeChainDir } from "../src/installLocation.js"; /** @type {string} */ // This checks the current file's dirname in a way that's compatible with: @@ -67,6 +68,17 @@ if (tool) { teardownDirectories(); } else if (command === "setup-ci") { setupCi(); +} else if (command === "get-install-dir") { + const installDir = getInstalledSafeChainDir(); + if (!installDir) { + ui.writeError( + "Install directory is only available for packaged safe-chain binaries.", + ); + process.exit(1); + } + + ui.writeInformation(installDir); + process.exit(0); } else if (command === "--version" || command === "-v" || command === "-v") { (async () => { ui.writeInformation(`Current safe-chain version: ${await getVersion()}`); @@ -88,7 +100,7 @@ function writeHelp() { ui.writeInformation( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( "teardown", - )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("help")}, ${chalk.cyan( + )}, ${chalk.cyan("setup-ci")}, ${chalk.cyan("get-install-dir")}, ${chalk.cyan("help")}, ${chalk.cyan( "--version", )}`, ); @@ -108,6 +120,11 @@ function writeHelp() { "safe-chain setup-ci", )}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`, ); + ui.writeInformation( + `- ${chalk.cyan( + "safe-chain get-install-dir", + )}: Print the install directory for packaged safe-chain binaries.`, + ); ui.writeInformation( `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( "-v", diff --git a/packages/safe-chain/src/config/configFile.js b/packages/safe-chain/src/config/configFile.js index 3fb0f21..d340130 100644 --- a/packages/safe-chain/src/config/configFile.js +++ b/packages/safe-chain/src/config/configFile.js @@ -3,6 +3,7 @@ import path from "path"; import os from "os"; import { ui } from "../environment/userInteraction.js"; import { getEcoSystem } from "./settings.js"; +import { getSafeChainBaseDir } from "./safeChainDir.js"; /** * @typedef {Object} SafeChainConfig @@ -304,8 +305,7 @@ function getConfigFilePath() { * @returns {string} */ export function getSafeChainDirectory() { - const homeDir = os.homedir(); - const safeChainDir = path.join(homeDir, ".safe-chain"); + const safeChainDir = getSafeChainBaseDir(); if (!fs.existsSync(safeChainDir)) { fs.mkdirSync(safeChainDir, { recursive: true }); diff --git a/packages/safe-chain/src/config/safeChainDir.js b/packages/safe-chain/src/config/safeChainDir.js new file mode 100644 index 0000000..4d4f013 --- /dev/null +++ b/packages/safe-chain/src/config/safeChainDir.js @@ -0,0 +1,71 @@ +import os from "os"; +import path from "path"; +import { fileURLToPath } from "url"; +import { getInstalledSafeChainDir } from "../installLocation.js"; + +/** + * @returns {string} + */ +export function getSafeChainBaseDir() { + return getInstalledSafeChainDir() ?? path.join(os.homedir(), ".safe-chain"); +} + +/** + * @returns {string} + */ +export function getBinDir() { + return path.join(getSafeChainBaseDir(), "bin"); +} + +/** + * @returns {string} + */ +export function getShimsDir() { + return path.join(getSafeChainBaseDir(), "shims"); +} + +/** + * @returns {string} + */ +export function getScriptsDir() { + return path.join(getSafeChainBaseDir(), "scripts"); +} + +/** + * @returns {string} + */ +export function getCertsDir() { + return path.join(getSafeChainBaseDir(), "certs"); +} + +/** + * Resolves the directory of the calling module. + * Falls back to __dirname when import.meta.url is unavailable (pkg CJS binary). + * @param {string | undefined} moduleUrl + * @returns {string} + */ +function resolveModuleDir(moduleUrl) { + if (moduleUrl) { + return path.dirname(fileURLToPath(moduleUrl)); + } + // eslint-disable-next-line no-undef + return __dirname; +} + +/** + * @param {string | undefined} moduleUrl + * @param {string} fileName + * @returns {string} + */ +export function getStartupScriptSourcePath(moduleUrl, fileName) { + return path.join(resolveModuleDir(moduleUrl), "startup-scripts", fileName); +} + +/** + * @param {string | undefined} moduleUrl + * @param {string} fileName + * @returns {string} + */ +export function getPathWrapperTemplatePath(moduleUrl, fileName) { + return path.join(resolveModuleDir(moduleUrl), "path-wrappers", "templates", fileName); +} diff --git a/packages/safe-chain/src/installLocation.js b/packages/safe-chain/src/installLocation.js new file mode 100644 index 0000000..52125be --- /dev/null +++ b/packages/safe-chain/src/installLocation.js @@ -0,0 +1,42 @@ +import path from "path"; + +/** @type {NodeJS.Process & { pkg?: unknown }} */ +const processWithPkg = process; + +/** + * @param {string} executablePath + * @returns {string | undefined} + */ +export function deriveInstallDirFromExecutablePath(executablePath) { + if (!executablePath) { + return undefined; + } + + const pathLibrary = executablePath.includes("\\") ? path.win32 : path.posix; + const executableDir = pathLibrary.dirname(executablePath); + if (pathLibrary.basename(executableDir) !== "bin") { + return undefined; + } + + return pathLibrary.dirname(executableDir); +} + +/** + * Returns the install directory for a packaged safe-chain binary. + * Custom installation directories only apply to packaged binary installs. + * For npm/global/dev-script executions this intentionally returns undefined, + * which causes callers to fall back to the default ~/.safe-chain layout. + * + * @param {{ isPackaged?: boolean, executablePath?: string }} [options] + * @returns {string | undefined} + */ +export function getInstalledSafeChainDir(options = {}) { + const isPackaged = options.isPackaged ?? Boolean(processWithPkg.pkg); + if (!isPackaged) { + return undefined; + } + + return deriveInstallDirFromExecutablePath( + options.executablePath ?? process.execPath, + ); +} diff --git a/packages/safe-chain/src/installLocation.spec.js b/packages/safe-chain/src/installLocation.spec.js new file mode 100644 index 0000000..558a05f --- /dev/null +++ b/packages/safe-chain/src/installLocation.spec.js @@ -0,0 +1,51 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { + deriveInstallDirFromExecutablePath, + getInstalledSafeChainDir, +} from "./installLocation.js"; + +describe("deriveInstallDirFromExecutablePath", () => { + it("derives the install dir from a Unix binary path", () => { + assert.strictEqual( + deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/bin/safe-chain"), + "/usr/local/.safe-chain", + ); + }); + + it("derives the install dir from a Windows binary path", () => { + assert.strictEqual( + deriveInstallDirFromExecutablePath("C:\\ProgramData\\safe-chain\\bin\\safe-chain.exe"), + "C:\\ProgramData\\safe-chain", + ); + }); + + it("returns undefined when the executable is not inside a bin directory", () => { + assert.strictEqual( + deriveInstallDirFromExecutablePath("/usr/local/.safe-chain/safe-chain"), + undefined, + ); + }); +}); + +describe("getInstalledSafeChainDir", () => { + it("returns undefined for non-packaged executions", () => { + assert.strictEqual( + getInstalledSafeChainDir({ + isPackaged: false, + executablePath: "/usr/local/.safe-chain/bin/safe-chain", + }), + undefined, + ); + }); + + it("returns the install dir for packaged executions", () => { + assert.strictEqual( + getInstalledSafeChainDir({ + isPackaged: true, + executablePath: "/usr/local/.safe-chain/bin/safe-chain", + }), + "/usr/local/.safe-chain", + ); + }); +}); diff --git a/packages/safe-chain/src/registryProxy/certUtils.js b/packages/safe-chain/src/registryProxy/certUtils.js index 3c8790c..3918177 100644 --- a/packages/safe-chain/src/registryProxy/certUtils.js +++ b/packages/safe-chain/src/registryProxy/certUtils.js @@ -1,9 +1,8 @@ import forge from "node-forge"; import path from "path"; import fs from "fs"; -import os from "os"; +import { getCertsDir } from "../config/safeChainDir.js"; -const certFolder = path.join(os.homedir(), ".safe-chain", "certs"); const ca = loadCa(); const certCache = new Map(); @@ -20,7 +19,7 @@ function createKeyIdentifier(publicKey) { } export function getCaCertPath() { - return path.join(certFolder, "ca-cert.pem"); + return path.join(getCertsDir(), "ca-cert.pem"); } /** @@ -112,6 +111,7 @@ export function generateCertForHost(hostname) { } function loadCa() { + const certFolder = getCertsDir(); const keyPath = path.join(certFolder, "ca-key.pem"); const certPath = path.join(certFolder, "ca-cert.pem"); diff --git a/packages/safe-chain/src/registryProxy/certUtils.spec.js b/packages/safe-chain/src/registryProxy/certUtils.spec.js new file mode 100644 index 0000000..4bf8c95 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certUtils.spec.js @@ -0,0 +1,71 @@ +import { describe, it, beforeEach, afterEach, mock } from "node:test"; +import assert from "node:assert"; + +describe("certUtils", () => { + let installedSafeChainDir; + + beforeEach(() => { + installedSafeChainDir = undefined; + mock.module("../config/safeChainDir.js", { + namedExports: { + getSafeChainBaseDir: () => installedSafeChainDir ?? "/home/test/.safe-chain", + getCertsDir: () => `${installedSafeChainDir ?? "/home/test/.safe-chain"}/certs`, + }, + }); + }); + + afterEach(() => { + mock.reset(); + }); + + it("stores CA certificates in the packaged install dir when available", async () => { + installedSafeChainDir = "/custom/safe-chain"; + + mock.module("fs", { + defaultExport: { + existsSync: () => false, + mkdirSync: () => {}, + writeFileSync: () => {}, + }, + }); + + mock.module("node-forge", { + defaultExport: { + pki: { + getPublicKeyFingerprint: () => "fingerprint", + rsa: { + generateKeyPair: () => ({ + publicKey: "public-key", + privateKey: "private-key", + }), + }, + createCertificate: () => ({ + publicKey: null, + serialNumber: "", + validity: { + notBefore: new Date(), + notAfter: new Date(), + }, + setSubject: () => {}, + setIssuer: () => {}, + setExtensions: () => {}, + sign: () => {}, + }), + privateKeyToPem: () => "private-key-pem", + certificateToPem: () => "certificate-pem", + }, + md: { + sha1: { create: () => "sha1" }, + sha256: { create: () => "sha256" }, + }, + }, + }); + + const { getCaCertPath } = await import("./certUtils.js"); + + assert.strictEqual( + getCaCertPath(), + "/custom/safe-chain/certs/ca-cert.pem", + ); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/helpers.js b/packages/safe-chain/src/shell-integration/helpers.js index dd86462..6f02479 100644 --- a/packages/safe-chain/src/shell-integration/helpers.js +++ b/packages/safe-chain/src/shell-integration/helpers.js @@ -127,20 +127,6 @@ export function getPackageManagerList() { return `${tools.join(", ")}, and ${lastTool} commands`; } -/** - * @returns {string} - */ -export function getShimsDir() { - return path.join(os.homedir(), ".safe-chain", "shims"); -} - -/** - * @returns {string} - */ -export function getScriptsDir() { - return path.join(os.homedir(), ".safe-chain", "scripts"); -} - /** * @param {string} executableName * diff --git a/packages/safe-chain/src/shell-integration/helpers.spec.js b/packages/safe-chain/src/shell-integration/helpers.spec.js index 4f18c36..e93a690 100644 --- a/packages/safe-chain/src/shell-integration/helpers.spec.js +++ b/packages/safe-chain/src/shell-integration/helpers.spec.js @@ -1,6 +1,6 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test"; import assert from "node:assert"; -import { tmpdir } from "node:os"; +import { tmpdir, homedir } from "node:os"; import fs from "node:fs"; import path from "path"; @@ -15,6 +15,7 @@ describe("removeLinesMatchingPatternTests", () => { mock.module("node:os", { namedExports: { EOL: "\r\n", // Simulate Windows line endings + homedir, tmpdir: tmpdir, platform: () => "linux", }, @@ -182,3 +183,30 @@ describe("removeLinesMatchingPatternTests", () => { assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines"); }); }); + +describe("getSafeChainBaseDir / getBinDir / getShimsDir / getScriptsDir", () => { + it("defaults base dir to ~/.safe-chain when no packaged install dir is available", async () => { + const { getSafeChainBaseDir } = await import("../config/safeChainDir.js"); + assert.strictEqual(getSafeChainBaseDir(), path.join(homedir(), ".safe-chain")); + }); + + it("getBinDir returns ~/.safe-chain/bin by default", async () => { + const { getBinDir } = await import("../config/safeChainDir.js"); + assert.strictEqual(getBinDir(), path.join(homedir(), ".safe-chain", "bin")); + }); + + it("getShimsDir returns ~/.safe-chain/shims by default", async () => { + const { getShimsDir } = await import("../config/safeChainDir.js"); + assert.strictEqual(getShimsDir(), path.join(homedir(), ".safe-chain", "shims")); + }); + + it("getScriptsDir returns ~/.safe-chain/scripts by default", async () => { + const { getScriptsDir } = await import("../config/safeChainDir.js"); + assert.strictEqual(getScriptsDir(), path.join(homedir(), ".safe-chain", "scripts")); + }); + + it("getCertsDir returns ~/.safe-chain/certs by default", async () => { + const { getCertsDir } = await import("../config/safeChainDir.js"); + assert.strictEqual(getCertsDir(), path.join(homedir(), ".safe-chain", "certs")); + }); +}); diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh index d6c9efd..5b318ff 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/unix-wrapper.template.sh @@ -4,13 +4,28 @@ # Function to remove shim from PATH (POSIX-compliant) remove_shim_from_path() { - echo "$PATH" | sed "s|$HOME/.safe-chain/shims:||g" + _safe_chain_phys=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) + if [ -z "$_safe_chain_phys" ]; then + echo "$PATH" + return + fi + _path=$(echo "$PATH" | sed "s|${_safe_chain_phys}:||g") + # Also remove via dirname of $0 directly — on macOS /tmp is a symlink to /private/tmp, + # so pwd -P resolves to /private/tmp/… but PATH may still contain /tmp/…. + _dir=$(dirname -- "$0") + case "$_dir" in + /*) [ "$_dir" != "$_safe_chain_phys" ] && _path=$(echo "$_path" | sed "s|${_dir}:||g") ;; + esac + echo "$_path" } if command -v safe-chain >/dev/null 2>&1; then # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" else + # safe-chain is not reachable — warn the user so they know protection is inactive + printf "\033[43;30mWarning:\033[0m safe-chain is not available to protect you from installing malware. {{PACKAGE_MANAGER}} will run without it.\n" >&2 + # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory) original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}}) if [ -n "$original_cmd" ]; then diff --git a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd index 082d553..b41fcfb 100644 --- a/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd +++ b/packages/safe-chain/src/shell-integration/path-wrappers/templates/windows-wrapper.template.cmd @@ -3,7 +3,8 @@ REM Generated wrapper for {{PACKAGE_MANAGER}} by safe-chain REM This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments REM Remove shim directory from PATH to prevent infinite loops -set "SHIM_DIR=%USERPROFILE%\.safe-chain\shims" +set "SHIM_DIR=%~dp0" +if "%SHIM_DIR:~-1%"=="\" set "SHIM_DIR=%SHIM_DIR:~0,-1%" call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%" REM Check if aikido command is available with clean PATH @@ -21,4 +22,4 @@ if %errorlevel%==0 ( REM If we get here, original command was not found echo Error: Could not find original {{PACKAGE_MANAGER}} >&2 exit /b 1 -) \ No newline at end of file +) diff --git a/packages/safe-chain/src/shell-integration/setup-ci.js b/packages/safe-chain/src/shell-integration/setup-ci.js index 762bd9b..f9e6767 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.js @@ -1,24 +1,14 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; -import { getPackageManagerList, knownAikidoTools, getShimsDir } from "./helpers.js"; +import { getPackageManagerList, knownAikidoTools } from "./helpers.js"; +import { + getShimsDir, + getBinDir, + getPathWrapperTemplatePath, +} from "../config/safeChainDir.js"; import fs from "fs"; import os from "os"; import path from "path"; -import { fileURLToPath } from "url"; - -/** @type {string} */ -// This checks the current file's dirname in a way that's compatible with: -// - Modulejs (import.meta.url) -// - ES modules (__dirname) -// This is needed because safe-chain's npm package is built using ES modules, -// but building the binaries requires commonjs. -let dirname; -if (import.meta.url) { - const filename = fileURLToPath(import.meta.url); - dirname = path.dirname(filename); -} else { - dirname = __dirname; -} /** * Loops over the detected shells and calls the setup function for each. @@ -31,7 +21,7 @@ export async function setupCi() { ui.emptyLine(); const shimsDir = getShimsDir(); - const binDir = path.join(os.homedir(), ".safe-chain", "bin"); + const binDir = getBinDir(); // Create the shims directory if it doesn't exist if (!fs.existsSync(shimsDir)) { fs.mkdirSync(shimsDir, { recursive: true }); @@ -50,12 +40,7 @@ export async function setupCi() { */ function createUnixShims(shimsDir) { // Read the template file - const templatePath = path.resolve( - dirname, - "path-wrappers", - "templates", - "unix-wrapper.template.sh" - ); + const templatePath = getPathWrapperTemplatePath(import.meta.url, "unix-wrapper.template.sh"); if (!fs.existsSync(templatePath)) { ui.writeError(`Template file not found: ${templatePath}`); @@ -89,12 +74,7 @@ function createUnixShims(shimsDir) { */ function createWindowsShims(shimsDir) { // Read the template file - const templatePath = path.resolve( - dirname, - "path-wrappers", - "templates", - "windows-wrapper.template.cmd" - ); + const templatePath = getPathWrapperTemplatePath(import.meta.url, "windows-wrapper.template.cmd"); if (!fs.existsSync(templatePath)) { ui.writeError(`Windows template file not found: ${templatePath}`); diff --git a/packages/safe-chain/src/shell-integration/setup-ci.spec.js b/packages/safe-chain/src/shell-integration/setup-ci.spec.js index b437157..7af41d6 100644 --- a/packages/safe-chain/src/shell-integration/setup-ci.spec.js +++ b/packages/safe-chain/src/shell-integration/setup-ci.spec.js @@ -22,12 +22,12 @@ describe("Setup CI shell integration", () => { fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true }); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"), - "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\nexec {{AIKIDO_COMMAND}} \"$@\"\n", + "#!/bin/bash\n# Template for {{PACKAGE_MANAGER}}\n_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)\nexec {{AIKIDO_COMMAND}} \"$@\"\n", "utf-8" ); fs.writeFileSync( path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), - "@echo off\nREM Template for {{PACKAGE_MANAGER}}\n{{AIKIDO_COMMAND}} %*\n", + "@echo off\nset \"SHIM_DIR=%~dp0\"\n{{AIKIDO_COMMAND}} %*\n", "utf-8" ); @@ -50,7 +50,15 @@ describe("Setup CI shell integration", () => { { tool: "yarn", aikidoCommand: "aikido-yarn" }, ], getPackageManagerList: () => "npm, yarn", + }, + }); + + mock.module("../config/safeChainDir.js", { + namedExports: { getShimsDir: () => mockShimsDir, + getBinDir: () => path.join(mockHomeDir, ".safe-chain", "bin"), + getPathWrapperTemplatePath: (_moduleUrl, fileName) => + path.join(mockTemplateDir, "path-wrappers", "templates", fileName), }, }); @@ -63,22 +71,6 @@ describe("Setup CI shell integration", () => { }, }); - // Mock path module to resolve templates correctly - mock.module("path", { - namedExports: { - join: path.join, - dirname: () => mockTemplateDir, - resolve: (...args) => path.resolve(mockTemplateDir, ...args.slice(1)), - }, - }); - - // Mock fileURLToPath - mock.module("url", { - namedExports: { - fileURLToPath: () => path.join(mockTemplateDir, "setup-ci.js"), - }, - }); - // Import setupCi module after mocking setupCi = (await import("./setup-ci.js")).setupCi; }); @@ -119,6 +111,10 @@ describe("Setup CI shell integration", () => { const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); assert.ok(npmShimContent.includes("#!/bin/bash"), "npm shim should have bash shebang"); + assert.ok( + npmShimContent.includes("_safe_chain_shims=$(CDPATH= cd -- \"$(dirname -- \"$0\")\" 2>/dev/null && pwd -P)"), + "npm shim should derive the shims directory from its own location", + ); }); it("should create Windows .cmd shims on win32 platform", async () => { @@ -142,6 +138,10 @@ describe("Setup CI shell integration", () => { assert.ok(npmShimContent.includes("aikido-npm"), "npm.cmd should contain aikido-npm"); assert.ok(npmShimContent.includes("@echo off"), "npm.cmd should have Windows batch header"); assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing"); + assert.ok( + npmShimContent.includes('set "SHIM_DIR=%~dp0"'), + "npm.cmd should derive the shims directory from its own location", + ); // Verify Unix shims were NOT created const unixNpmShim = path.join(mockShimsDir, "npm"); diff --git a/packages/safe-chain/src/shell-integration/setup.js b/packages/safe-chain/src/shell-integration/setup.js index 66c6533..04534df 100644 --- a/packages/safe-chain/src/shell-integration/setup.js +++ b/packages/safe-chain/src/shell-integration/setup.js @@ -1,28 +1,10 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; -import { - knownAikidoTools, - getPackageManagerList, - getScriptsDir, -} from "./helpers.js"; +import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; +import { getScriptsDir, getStartupScriptSourcePath } from "../config/safeChainDir.js"; import fs from "fs"; import path from "path"; -import { fileURLToPath } from "url"; - -/** @type {string} */ -// This checks the current file's dirname in a way that's compatible with: -// - Modulejs (import.meta.url) -// - ES modules (__dirname) -// This is needed because safe-chain's npm package is built using ES modules, -// but building the binaries requires commonjs. -let dirname; -if (import.meta.url) { - const filename = fileURLToPath(import.meta.url); - dirname = path.dirname(filename); -} else { - dirname = __dirname; -} /** * Loops over the detected shells and calls the setup function for each. @@ -122,8 +104,7 @@ function copyStartupFiles() { fs.mkdirSync(targetDir, { recursive: true }); } - // Use absolute path for source - const sourcePath = path.join(dirname, "startup-scripts", file); + const sourcePath = getStartupScriptSourcePath(import.meta.url, file); fs.copyFileSync(sourcePath, targetPath); } } diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish index fdb501f..06960ef 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-fish.fish @@ -1,4 +1,7 @@ -set -gx PATH $PATH $HOME/.safe-chain/bin +set -l safe_chain_script (status filename) +set -l safe_chain_scripts_dir (dirname $safe_chain_script) +set -l safe_chain_base (dirname $safe_chain_scripts_dir) +set -gx PATH $PATH $safe_chain_base/bin function npx wrapSafeChainCommand "npx" $argv diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh index ea09ef0..452e62d 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-posix.sh @@ -1,4 +1,16 @@ -export PATH="$PATH:$HOME/.safe-chain/bin" +if [ -n "${BASH_SOURCE[0]:-}" ]; then + _sc_script_path="${BASH_SOURCE[0]}" +elif [ -n "${ZSH_VERSION:-}" ]; then + # ${(%):-%x} uses Zsh prompt expansion to get the sourced file's path. + # eval is required so other shells don't try to parse the Zsh-specific syntax. + eval '_sc_script_path="${(%):-%x}"' +else + _sc_script_path="$0" +fi +_sc_scripts_dir=$(CDPATH= cd -- "$(dirname -- "$_sc_script_path")" 2>/dev/null && pwd -P) +_sc_base=$(dirname -- "$_sc_scripts_dir") +export PATH="$PATH:${_sc_base}/bin" +unset _sc_base _sc_script_path _sc_scripts_dir function npx() { wrapSafeChainCommand "npx" "$@" diff --git a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 index 4cdefee..f65deb9 100644 --- a/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 +++ b/packages/safe-chain/src/shell-integration/startup-scripts/init-pwsh.ps1 @@ -2,7 +2,8 @@ # $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell $isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true } $pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' } -$safeChainBin = Join-Path (Join-Path $HOME '.safe-chain') 'bin' +$safeChainBase = Split-Path -Parent $PSScriptRoot +$safeChainBin = Join-Path $safeChainBase 'bin' $env:PATH = "$env:PATH$pathSeparator$safeChainBin" function npx { diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.js index cc50223..956429d 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.js @@ -3,8 +3,10 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync, spawnSync } from "child_process"; import * as os from "os"; +import path from "path"; const shellName = "Bash"; const executableName = "bash"; @@ -32,10 +34,10 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain bash initialization script (~/.safe-chain/scripts/init-posix.sh) + // Remove sourcing line to disable safe-chain shell integration removeLinesMatchingPattern( startupFile, - /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, + /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); @@ -44,10 +46,11 @@ function teardown(tools) { function setup() { const startupFile = getStartupFile(); + const scriptsDir = getShellScriptsDir(); addLineToFile( startupFile, - `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script`, + `source ${path.posix.join(scriptsDir, "init-posix.sh")} # Safe-chain bash initialization script`, eol ); @@ -94,6 +97,51 @@ function windowsFixPath(path) { } } +function getShellScriptsDir() { + return toBashPath(getScriptsDir()); +} + +/** + * @param {string} path + * + * @returns {string} + */ +function toBashPath(path) { + try { + if (os.platform() !== "win32") { + return path.replace(/\\/g, "/"); + } + + const directWindowsPath = windowsPathToBashPath(path); + if (directWindowsPath) { + return directWindowsPath; + } + + if (hasCygpath()) { + return convertCygwinPathToUnix(path); + } + + return path.replace(/\\/g, "/"); + } catch { + return path.replace(/\\/g, "/"); + } +} + +/** + * @param {string} path + * + * @returns {string | undefined} + */ +function windowsPathToBashPath(path) { + const match = /^([A-Za-z]):[\\/](.*)$/.exec(path); + if (!match) { + return undefined; + } + + const [, driveLetter, rest] = match; + return `/${driveLetter.toLowerCase()}/${rest.replace(/\\/g, "/")}`; +} + function hasCygpath() { try { var result = spawnSync("where", ["cygpath"], { shell: executableName }); @@ -123,18 +171,40 @@ function cygpathw(path) { } } +/** + * @param {string} path + * + * @returns {string} + */ +function convertCygwinPathToUnix(path) { + try { + var result = spawnSync("cygpath", ["-u", path], { + encoding: "utf8", + shell: executableName, + }); + if (result.status === 0) { + return result.stdout.trim(); + } + return path.replace(/\\/g, "/"); + } catch { + return path.replace(/\\/g, "/"); + } +} + function getManualTeardownInstructions() { + const scriptsDir = getShellScriptsDir(); return [ `Remove the following line from your ~/.bashrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, + ` source ${path.posix.join(scriptsDir, "init-posix.sh")}`, `Then restart your terminal or run: source ~/.bashrc`, ]; } function getManualSetupInstructions() { + const scriptsDir = getShellScriptsDir(); return [ `Add the following line to your ~/.bashrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, + ` source ${path.posix.join(scriptsDir, "init-posix.sh")}`, `Then restart your terminal or run: source ~/.bashrc`, ]; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js index aa7159f..ac80d1f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/bash.spec.js @@ -9,6 +9,7 @@ describe("Bash shell integration", () => { let mockStartupFile; let bash; let windowsCygwinPath = ""; + let mockScriptsDir = "/test-home/.safe-chain/scripts"; let platform = "linux"; beforeEach(async () => { @@ -35,6 +36,12 @@ describe("Bash shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => mockScriptsDir, + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -61,6 +68,17 @@ describe("Bash shell integration", () => { stdout: windowsCygwinPath + "\n", }; } + + if ( + command === "cygpath" && + args[0] === "-u" && + args[1] === mockScriptsDir + ) { + return { + status: 0, + stdout: "/c/test-home/.safe-chain/scripts\n", + }; + } }, }, }); @@ -87,6 +105,7 @@ describe("Bash shell integration", () => { // Reset mocks mock.reset(); + mockScriptsDir = "/test-home/.safe-chain/scripts"; platform = "linux"; }); @@ -109,7 +128,7 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" ) ); }); @@ -129,7 +148,24 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(windowsCygwinPath, "utf-8"); assert.ok( content.includes( - "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + "source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" + ) + ); + }); + + it("should write a bash-compatible scripts path on Windows", () => { + platform = "win32"; + windowsCygwinPath = mockStartupFile; + mockScriptsDir = "C:\\test-home\\.safe-chain\\scripts"; + mockStartupFile = "DUMMY"; + + const result = bash.setup(); + assert.strictEqual(result, true); + + const content = fs.readFileSync(windowsCygwinPath, "utf-8"); + assert.ok( + content.includes( + "source /c/test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script" ) ); }); @@ -209,13 +245,13 @@ describe("Bash shell integration", () => { // Setup bash.setup(tools); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); // Teardown bash.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") ); }); @@ -236,7 +272,7 @@ describe("Bash shell integration", () => { const initialContent = [ "#!/bin/bash", "alias npm='old-npm'", - "source ~/.safe-chain/scripts/init-posix.sh", + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script", "alias ls='ls --color=auto'", ].join("\n"); @@ -247,7 +283,7 @@ describe("Bash shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("alias npm=")); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain bash initialization script") ); assert.ok(content.includes("alias ls=")); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.js index a623d0b..95c867b 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.js @@ -3,7 +3,9 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; +import path from "path"; const shellName = "Fish"; const executableName = "fish"; @@ -31,10 +33,10 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain fish initialization script (~/.safe-chain/scripts/init-fish.fish) + // Remove sourcing line to prevent safe-chain initialization in future shell sessions removeLinesMatchingPattern( startupFile, - /^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/, + /^source\s+.*init-fish\.fish.*#\s*Safe-chain/, eol ); @@ -46,7 +48,7 @@ function setup() { addLineToFile( startupFile, - `source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script`, + `source ${path.join(getScriptsDir(), "init-fish.fish")} # Safe-chain Fish initialization script`, eol ); @@ -69,7 +71,7 @@ function getStartupFile() { function getManualTeardownInstructions() { return [ `Remove the following line from your ~/.config/fish/config.fish file:`, - ` source ~/.safe-chain/scripts/init-fish.fish`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, `Then restart your terminal or run: source ~/.config/fish/config.fish`, ]; } @@ -77,7 +79,7 @@ function getManualTeardownInstructions() { function getManualSetupInstructions() { return [ `Add the following line to your ~/.config/fish/config.fish file:`, - ` source ~/.safe-chain/scripts/init-fish.fish`, + ` source ${path.join(getScriptsDir(), "init-fish.fish")}`, `Then restart your terminal or run: source ~/.config/fish/config.fish`, ]; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js index e138957..c1c5715 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/fish.spec.js @@ -33,6 +33,12 @@ describe("Fish shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => "/test-home/.safe-chain/scripts", + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -72,7 +78,7 @@ describe("Fish shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script') + content.includes('source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script') ); }); @@ -81,7 +87,7 @@ describe("Fish shell integration", () => { fish.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); - const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; + const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; assert.strictEqual(sourceMatches, 2, "Should allow multiple source lines (helper doesn't dedupe)"); }); }); @@ -93,7 +99,7 @@ describe("Fish shell integration", () => { "alias npm 'aikido-npm'", "alias npx 'aikido-npx'", "alias yarn 'aikido-yarn'", - "source ~/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", + "source /test-home/.safe-chain/scripts/init-fish.fish # Safe-chain Fish initialization script", "alias ls 'ls --color=auto'", "alias grep 'grep --color=auto'", ].join("\n"); @@ -107,7 +113,7 @@ describe("Fish shell integration", () => { assert.ok(!content.includes("alias npm ")); assert.ok(!content.includes("alias npx ")); assert.ok(!content.includes("alias yarn ")); - assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish")); + assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish")); assert.ok(content.includes("alias ls ")); assert.ok(content.includes("alias grep ")); }); @@ -162,12 +168,12 @@ describe("Fish shell integration", () => { // Setup fish.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes('source ~/.safe-chain/scripts/init-fish.fish')); + assert.ok(content.includes('source /test-home/.safe-chain/scripts/init-fish.fish')); // Teardown fish.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(!content.includes("source ~/.safe-chain/scripts/init-fish.fish")); + assert.ok(!content.includes("source /test-home/.safe-chain/scripts/init-fish.fish")); }); it("should handle multiple setup calls", () => { @@ -176,7 +182,7 @@ describe("Fish shell integration", () => { fish.setup(); const content = fs.readFileSync(mockStartupFile, "utf-8"); - const sourceMatches = (content.match(/source ~\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; + const sourceMatches = (content.match(/source \/test-home\/\.safe-chain\/scripts\/init-fish\.fish/g) || []).length; assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle"); }); }); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js index 4bbc332..2717e36 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.js @@ -4,7 +4,9 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; +import path from "path"; const shellName = "PowerShell Core"; const executableName = "pwsh"; @@ -30,10 +32,10 @@ function teardown(tools) { ); } - // Remove the line that sources the safe-chain PowerShell initialization script + // Remove sourcing line to prevent shell from loading safe-chain after uninstallation removeLinesMatchingPattern( startupFile, - /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/, + /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); return true; @@ -52,7 +54,7 @@ async function setup() { addLineToFile( startupFile, - `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`, + `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, ); return true; @@ -74,7 +76,7 @@ function getStartupFile() { function getManualTeardownInstructions() { return [ `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, `Then restart your terminal or run: . $PROFILE`, ]; } @@ -82,7 +84,7 @@ function getManualTeardownInstructions() { function getManualSetupInstructions() { return [ `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, `Then restart your terminal or run: . $PROFILE`, ]; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js index de2c14b..b14c73f 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/powershell.spec.js @@ -43,6 +43,12 @@ describe("PowerShell Core shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => "/test-home/.safe-chain/scripts", + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -83,7 +89,7 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', ), ); }); @@ -93,7 +99,7 @@ describe("PowerShell Core shell integration", () => { it("should remove init-pwsh.ps1 source line", () => { const initialContent = [ "# PowerShell profile", - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', "Set-Alias ls Get-ChildItem", "Set-Alias grep Select-String", ].join("\n"); @@ -105,7 +111,7 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); assert.ok(content.includes("Set-Alias ls ")); assert.ok(content.includes("Set-Alias grep ")); @@ -180,14 +186,14 @@ describe("PowerShell Core shell integration", () => { await powershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); // Teardown powershell.teardown(knownAikidoTools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); }); @@ -198,7 +204,7 @@ describe("PowerShell Core shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = ( - content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) || + content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) || [] ).length; assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js index 3e81da7..7213d38 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.js @@ -4,7 +4,9 @@ import { removeLinesMatchingPattern, validatePowerShellExecutionPolicy, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; +import path from "path"; const shellName = "Windows PowerShell"; const executableName = "powershell"; @@ -30,10 +32,10 @@ function teardown(tools) { ); } - // Remove the line that sources the safe-chain PowerShell initialization script + // Remove sourcing line to clean up safe-chain integration from the shell profile removeLinesMatchingPattern( startupFile, - /^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/, + /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/, ); return true; @@ -52,7 +54,7 @@ async function setup() { addLineToFile( startupFile, - `. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script`, + `. "${path.join(getScriptsDir(), "init-pwsh.ps1")}" # Safe-chain PowerShell initialization script`, ); return true; @@ -74,7 +76,7 @@ function getStartupFile() { function getManualTeardownInstructions() { return [ `Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, `Then restart your terminal or run: . $PROFILE`, ]; } @@ -82,7 +84,7 @@ function getManualTeardownInstructions() { function getManualSetupInstructions() { return [ `Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, - ` . "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"`, + ` . "${path.join(getScriptsDir(), "init-pwsh.ps1")}"`, `Then restart your terminal or run: . $PROFILE`, ]; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js index 561d0d4..277a3f7 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/windowsPowershell.spec.js @@ -43,6 +43,12 @@ describe("Windows PowerShell shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => "/test-home/.safe-chain/scripts", + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -83,7 +89,7 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', ), ); }); @@ -93,7 +99,7 @@ describe("Windows PowerShell shell integration", () => { it("should remove init-pwsh.ps1 source line", () => { const initialContent = [ "# Windows PowerShell profile", - '. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1" # Safe-chain PowerShell initialization script', + '. "/test-home/.safe-chain/scripts/init-pwsh.ps1" # Safe-chain PowerShell initialization script', "Set-Alias ls Get-ChildItem", "Set-Alias grep Select-String", ].join("\n"); @@ -105,7 +111,7 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); assert.ok(content.includes("Set-Alias ls ")); assert.ok(content.includes("Set-Alias grep ")); @@ -180,14 +186,14 @@ describe("Windows PowerShell shell integration", () => { await windowsPowershell.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); // Teardown windowsPowershell.teardown(knownAikidoTools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), + !content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'), ); }); @@ -198,7 +204,7 @@ describe("Windows PowerShell shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); const sourceMatches = ( - content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) || + content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) || [] ).length; assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js index f187af3..c3e8d73 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.js @@ -3,7 +3,9 @@ import { doesExecutableExistOnSystem, removeLinesMatchingPattern, } from "../helpers.js"; +import { getScriptsDir } from "../../config/safeChainDir.js"; import { execSync } from "child_process"; +import path from "path"; const shellName = "Zsh"; const executableName = "zsh"; @@ -31,10 +33,10 @@ function teardown(tools) { ); } - // Removes the line that sources the safe-chain zsh initialization script (~/.safe-chain/scripts/init-posix.sh) + // Remove sourcing line to complete shell integration cleanup removeLinesMatchingPattern( startupFile, - /^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, + /^source\s+.*init-posix\.sh.*#\s*Safe-chain/, eol ); @@ -46,7 +48,7 @@ function setup() { addLineToFile( startupFile, - `source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script`, + `source ${path.join(getScriptsDir(), "init-posix.sh")} # Safe-chain Zsh initialization script`, eol ); @@ -69,7 +71,7 @@ function getStartupFile() { function getManualTeardownInstructions() { return [ `Remove the following line from your ~/.zshrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, `Then restart your terminal or run: source ~/.zshrc`, ]; } @@ -77,7 +79,7 @@ function getManualTeardownInstructions() { function getManualSetupInstructions() { return [ `Add the following line to your ~/.zshrc file:`, - ` source ~/.safe-chain/scripts/init-posix.sh`, + ` source ${path.join(getScriptsDir(), "init-posix.sh")}`, `Then restart your terminal or run: source ~/.zshrc`, ]; } diff --git a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js index 99106ec..50af5ca 100644 --- a/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js +++ b/packages/safe-chain/src/shell-integration/supported-shells/zsh.spec.js @@ -33,6 +33,12 @@ describe("Zsh shell integration", () => { }, }); + mock.module("../../config/safeChainDir.js", { + namedExports: { + getScriptsDir: () => "/test-home/.safe-chain/scripts", + }, + }); + // Mock child_process execSync mock.module("child_process", { namedExports: { @@ -73,7 +79,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( content.includes( - "source ~/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script" + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script" ) ); }); @@ -83,7 +89,7 @@ describe("Zsh shell integration", () => { assert.strictEqual(result, true); const content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); }); }); @@ -114,7 +120,7 @@ describe("Zsh shell integration", () => { it("should remove zsh initialization script source line", () => { const initialContent = [ "#!/bin/zsh", - "source ~/.safe-chain/scripts/init-posix.sh", + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", "alias ls='ls --color=auto'", ].join("\n"); @@ -125,7 +131,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script") ); assert.ok(content.includes("alias ls=")); }); @@ -180,13 +186,13 @@ describe("Zsh shell integration", () => { // Setup zsh.setup(); let content = fs.readFileSync(mockStartupFile, "utf-8"); - assert.ok(content.includes("source ~/.safe-chain/scripts/init-posix.sh")); + assert.ok(content.includes("source /test-home/.safe-chain/scripts/init-posix.sh")); // Teardown zsh.teardown(tools); content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh") ); }); @@ -207,7 +213,7 @@ describe("Zsh shell integration", () => { const initialContent = [ "#!/bin/zsh", "alias npm='old-npm'", - "source ~/.safe-chain/scripts/init-posix.sh", + "source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script", "alias ls='ls --color=auto'", ].join("\n"); @@ -218,7 +224,7 @@ describe("Zsh shell integration", () => { const content = fs.readFileSync(mockStartupFile, "utf-8"); assert.ok(!content.includes("alias npm=")); assert.ok( - !content.includes("source ~/.safe-chain/scripts/init-posix.sh") + !content.includes("source /test-home/.safe-chain/scripts/init-posix.sh # Safe-chain Zsh initialization script") ); assert.ok(content.includes("alias ls=")); }); diff --git a/packages/safe-chain/src/shell-integration/teardown.js b/packages/safe-chain/src/shell-integration/teardown.js index bcf6346..cdeeae2 100644 --- a/packages/safe-chain/src/shell-integration/teardown.js +++ b/packages/safe-chain/src/shell-integration/teardown.js @@ -1,7 +1,8 @@ import chalk from "chalk"; import { ui } from "../environment/userInteraction.js"; import { detectShells } from "./shellDetection.js"; -import { knownAikidoTools, getPackageManagerList, getShimsDir, getScriptsDir } from "./helpers.js"; +import { knownAikidoTools, getPackageManagerList } from "./helpers.js"; +import { getShimsDir, getScriptsDir } from "../config/safeChainDir.js"; import fs from "fs"; /** @@ -109,4 +110,5 @@ export async function teardownDirectories() { ); } } + } diff --git a/test/e2e/DockerTestContainer.js b/test/e2e/DockerTestContainer.js index 95a467c..cd48c4e 100644 --- a/test/e2e/DockerTestContainer.js +++ b/test/e2e/DockerTestContainer.js @@ -84,10 +84,14 @@ export class DockerTestContainer { } } - async openShell(shell) { + async openShell(shell, { user } = {}) { + const execArgs = user + ? ["exec", "-it", "-u", user, this.containerName, shell] + : ["exec", "-it", this.containerName, shell]; + let ptyProcess = pty.spawn( "docker", - ["exec", "-it", this.containerName, shell], + execArgs, { name: "xterm-color", cols: 80,