Merge pull request #411 from AikidoSec/feat/dynamic-install-dir

Add support for custom install directory
This commit is contained in:
Reinier Criel 2026-04-16 10:04:25 -07:00 committed by GitHub
commit 782af8e789
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1120 additions and 302 deletions

View file

@ -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/npm.json` (JavaScript new packages list)
- `/releases/pypi.json` (Python 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 # 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. 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 { environment {
// Jenkins does not automatically persist PATH updates from setup-ci, // Jenkins does not automatically persist PATH updates from setup-ci,
// so add the shims + binary directory explicitly for all stages. // 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}" 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 # Install safe-chain
RUN curl -fsSL https://github.com/AikidoSec/safe-chain/releases/latest/download/install-safe-chain.sh | sh -s -- --ci 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}" ENV PATH="/root/.safe-chain/shims:/root/.safe-chain/bin:${PATH}"
``` ```

View file

@ -4,11 +4,52 @@
param( param(
[switch]$ci, [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 $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" $RepoUrl = "https://github.com/AikidoSec/safe-chain"
# Ensure TLS 1.2 is enabled for downloads # 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 # Check and uninstall npm global package if present
function Remove-NpmInstallation { function Remove-NpmInstallation {
# Check if npm is available # Check if npm is available
@ -149,19 +243,7 @@ function Remove-VoltaInstallation {
# Main installation # Main installation
function Install-SafeChain { function Install-SafeChain {
# Show deprecation warning if SAFE_CHAIN_VERSION is set Write-VersionDeprecationWarning
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 ""
}
# Fetch latest version if VERSION is not set # Fetch latest version if VERSION is not set
if ([string]::IsNullOrWhiteSpace($Version)) { if ([string]::IsNullOrWhiteSpace($Version)) {
@ -192,7 +274,7 @@ function Install-SafeChain {
# Detect platform # Detect platform
$arch = Get-Architecture $arch = Get-Architecture
$binaryName = "safe-chain-win-$arch.exe" $binaryName = Get-BinaryName -Architecture $arch
Write-Info "Detected architecture: $arch" Write-Info "Detected architecture: $arch"
@ -238,31 +320,7 @@ function Install-SafeChain {
Write-Info "Binary installed to: $finalFile" Write-Info "Binary installed to: $finalFile"
# Build setup command based on parameters Invoke-SafeChainSetup -BinaryPath $finalFile -InstallDirectory $InstallDir
$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."
}
} }
# Run installation # Run installation

View file

@ -6,9 +6,53 @@
set -e # Exit on error 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 # Configuration
VERSION="${SAFE_CHAIN_VERSION:-}" # Will be fetched from latest release if not set 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" REPO_URL="https://github.com/AikidoSec/safe-chain"
# Colors for output # Colors for output
@ -126,6 +170,75 @@ download() {
fi 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 # Check and uninstall npm global package if present
remove_npm_installation() { remove_npm_installation() {
if ! command_exists npm; then if ! command_exists npm; then
@ -229,19 +342,39 @@ remove_nvm_installation() {
# Parse command-line arguments # Parse command-line arguments
parse_arguments() { parse_arguments() {
for arg in "$@"; do while [ $# -gt 0 ]; do
case "$arg" in case "$1" in
--ci) --ci)
USE_CI_SETUP=true 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) --include-python)
warn "--include-python is deprecated and ignored. Python ecosystem is now included by default." warn "--include-python is deprecated and ignored. Python ecosystem is now included by default."
;; ;;
*) *)
error "Unknown argument: $arg" error "Unknown argument: $1"
;; ;;
esac esac
shift
done done
validate_install_dir "${SAFE_CHAIN_BASE}"
INSTALL_DIR="${SAFE_CHAIN_BASE}/bin"
} }
# Main installation # Main installation
@ -252,25 +385,9 @@ main() {
# Parse command-line arguments # Parse command-line arguments
parse_arguments "$@" parse_arguments "$@"
# Show deprecation warning if SAFE_CHAIN_VERSION is set warn_deprecated_version_env
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
# Fetch latest version if VERSION is not set ensure_version
if [ -z "$VERSION" ]; then
info "Fetching latest release version..."
VERSION=$(fetch_latest_version)
fi
# Check if the requested version is already installed # Check if the requested version is already installed
if is_version_installed "$VERSION"; then if is_version_installed "$VERSION"; then
@ -294,11 +411,7 @@ main() {
# Detect platform # Detect platform
OS=$(detect_os) OS=$(detect_os)
ARCH=$(detect_arch) ARCH=$(detect_arch)
if [ "$OS" = "win" ]; then BINARY_NAME=$(get_binary_name "$OS" "$ARCH")
BINARY_NAME="safe-chain-${OS}-${ARCH}.exe"
else
BINARY_NAME="safe-chain-${OS}-${ARCH}"
fi
info "Detected platform: ${OS}-${ARCH}" info "Detected platform: ${OS}-${ARCH}"
@ -316,11 +429,7 @@ main() {
download "$DOWNLOAD_URL" "$TEMP_FILE" download "$DOWNLOAD_URL" "$TEMP_FILE"
# Rename and make executable # Rename and make executable
if [ "$OS" = "win" ]; then FINAL_FILE=$(get_final_binary_path "$OS")
FINAL_FILE="${INSTALL_DIR}/safe-chain.exe"
else
FINAL_FILE="${INSTALL_DIR}/safe-chain"
fi
mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE" mv "$TEMP_FILE" "$FINAL_FILE" || error "Failed to move binary to $FINAL_FILE"
if [ "$OS" != "win" ]; then if [ "$OS" != "win" ]; then
chmod +x "$FINAL_FILE" || error "Failed to make binary executable" chmod +x "$FINAL_FILE" || error "Failed to make binary executable"
@ -328,20 +437,7 @@ main() {
info "Binary installed to: $FINAL_FILE" info "Binary installed to: $FINAL_FILE"
# Build setup command based on arguments run_setup_command "$FINAL_FILE"
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
} }
main "$@" main "$@"

View file

@ -4,8 +4,6 @@
# Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform) # Use HOME on Unix, USERPROFILE on Windows (PowerShell Core is cross-platform)
$HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE } $HomeDir = if ($env:HOME) { $env:HOME } else { $env:USERPROFILE }
$DotSafeChain = Join-Path $HomeDir ".safe-chain"
$InstallDir = Join-Path $DotSafeChain "bin"
# Helper functions # Helper functions
function Write-Info { function Write-Info {
@ -24,6 +22,146 @@ function Write-Error-Custom {
exit 1 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 # Check and uninstall npm global package if present
function Remove-NpmInstallation { function Remove-NpmInstallation {
# Check if npm is available # Check if npm is available
@ -76,49 +214,9 @@ function Remove-VoltaInstallation {
# Main uninstallation # Main uninstallation
function Uninstall-SafeChain { function Uninstall-SafeChain {
Write-Info "Uninstalling safe-chain..." Write-Info "Uninstalling safe-chain..."
$DotSafeChain = Get-SafeChainInstallDir
# Run teardown if safe-chain is available $safeChainPath = Find-SafeChainBinary -DotSafeChain $DotSafeChain
# Check for both safe-chain.exe (Windows) and safe-chain (Unix) since PowerShell Core runs on all platforms Invoke-SafeChainTeardown -SafeChainPath $safeChainPath
$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."
}
# Remove npm and Volta installations # Remove npm and Volta installations
Remove-NpmInstallation Remove-NpmInstallation

View file

@ -7,7 +7,6 @@
set -e # Exit on error set -e # Exit on error
# Configuration # Configuration
DOT_SAFE_CHAIN="${HOME}/.safe-chain"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
@ -34,6 +33,159 @@ command_exists() {
command -v "$1" >/dev/null 2>&1 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 # Check and uninstall npm global package if present
remove_npm_installation() { remove_npm_installation() {
if ! command_exists npm; then if ! command_exists npm; then
@ -139,17 +291,9 @@ remove_nvm_installation() {
# Main uninstallation # Main uninstallation
main() { main() {
SAFE_CHAIN_LOCATION="$DOT_SAFE_CHAIN/bin/safe-chain" DOT_SAFE_CHAIN=$(get_install_dir)
SAFE_CHAIN_COMMAND=$(find_installed_safe_chain_binary "$DOT_SAFE_CHAIN" || true)
if [ -x "$SAFE_CHAIN_LOCATION" ]; then run_safe_chain_teardown "$SAFE_CHAIN_COMMAND"
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
# Check for existing safe-chain installation through nvm, volta, or npm # Check for existing safe-chain installation through nvm, volta, or npm
remove_npm_installation remove_npm_installation

View file

@ -16,6 +16,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import fs from "fs"; import fs from "fs";
import { knownAikidoTools } from "../src/shell-integration/helpers.js"; import { knownAikidoTools } from "../src/shell-integration/helpers.js";
import { getInstalledSafeChainDir } from "../src/installLocation.js";
/** @type {string} */ /** @type {string} */
// This checks the current file's dirname in a way that's compatible with: // This checks the current file's dirname in a way that's compatible with:
@ -67,6 +68,17 @@ if (tool) {
teardownDirectories(); teardownDirectories();
} else if (command === "setup-ci") { } else if (command === "setup-ci") {
setupCi(); 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") { } else if (command === "--version" || command === "-v" || command === "-v") {
(async () => { (async () => {
ui.writeInformation(`Current safe-chain version: ${await getVersion()}`); ui.writeInformation(`Current safe-chain version: ${await getVersion()}`);
@ -88,7 +100,7 @@ function writeHelp() {
ui.writeInformation( ui.writeInformation(
`Available commands: ${chalk.cyan("setup")}, ${chalk.cyan( `Available commands: ${chalk.cyan("setup")}, ${chalk.cyan(
"teardown", "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", "--version",
)}`, )}`,
); );
@ -108,6 +120,11 @@ function writeHelp() {
"safe-chain setup-ci", "safe-chain setup-ci",
)}: This will setup safe-chain for CI environments by creating shims and modifying the PATH.`, )}: 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( ui.writeInformation(
`- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan( `- ${chalk.cyan("safe-chain --version")} (or ${chalk.cyan(
"-v", "-v",

View file

@ -3,6 +3,7 @@ import path from "path";
import os from "os"; import os from "os";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { getEcoSystem } from "./settings.js"; import { getEcoSystem } from "./settings.js";
import { getSafeChainBaseDir } from "./safeChainDir.js";
/** /**
* @typedef {Object} SafeChainConfig * @typedef {Object} SafeChainConfig
@ -304,8 +305,7 @@ function getConfigFilePath() {
* @returns {string} * @returns {string}
*/ */
export function getSafeChainDirectory() { export function getSafeChainDirectory() {
const homeDir = os.homedir(); const safeChainDir = getSafeChainBaseDir();
const safeChainDir = path.join(homeDir, ".safe-chain");
if (!fs.existsSync(safeChainDir)) { if (!fs.existsSync(safeChainDir)) {
fs.mkdirSync(safeChainDir, { recursive: true }); fs.mkdirSync(safeChainDir, { recursive: true });

View file

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

View file

@ -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,
);
}

View file

@ -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",
);
});
});

View file

@ -1,9 +1,8 @@
import forge from "node-forge"; import forge from "node-forge";
import path from "path"; import path from "path";
import fs from "fs"; 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 ca = loadCa();
const certCache = new Map(); const certCache = new Map();
@ -20,7 +19,7 @@ function createKeyIdentifier(publicKey) {
} }
export function getCaCertPath() { 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() { function loadCa() {
const certFolder = getCertsDir();
const keyPath = path.join(certFolder, "ca-key.pem"); const keyPath = path.join(certFolder, "ca-key.pem");
const certPath = path.join(certFolder, "ca-cert.pem"); const certPath = path.join(certFolder, "ca-cert.pem");

View file

@ -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",
);
});
});

View file

@ -127,20 +127,6 @@ export function getPackageManagerList() {
return `${tools.join(", ")}, and ${lastTool} commands`; 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 * @param {string} executableName
* *

View file

@ -1,6 +1,6 @@
import { describe, it, beforeEach, afterEach, mock } from "node:test"; import { describe, it, beforeEach, afterEach, mock } from "node:test";
import assert from "node:assert"; import assert from "node:assert";
import { tmpdir } from "node:os"; import { tmpdir, homedir } from "node:os";
import fs from "node:fs"; import fs from "node:fs";
import path from "path"; import path from "path";
@ -15,6 +15,7 @@ describe("removeLinesMatchingPatternTests", () => {
mock.module("node:os", { mock.module("node:os", {
namedExports: { namedExports: {
EOL: "\r\n", // Simulate Windows line endings EOL: "\r\n", // Simulate Windows line endings
homedir,
tmpdir: tmpdir, tmpdir: tmpdir,
platform: () => "linux", platform: () => "linux",
}, },
@ -182,3 +183,30 @@ describe("removeLinesMatchingPatternTests", () => {
assert.strictEqual(resultLines.length, 5, "Should have exactly 5 lines"); 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"));
});
});

View file

@ -4,13 +4,28 @@
# Function to remove shim from PATH (POSIX-compliant) # Function to remove shim from PATH (POSIX-compliant)
remove_shim_from_path() { 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 if command -v safe-chain >/dev/null 2>&1; then
# Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops # Remove shim directory from PATH when calling {{AIKIDO_COMMAND}} to prevent infinite loops
PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@" PATH=$(remove_shim_from_path) exec safe-chain {{PACKAGE_MANAGER}} "$@"
else 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) # Dynamically find original {{PACKAGE_MANAGER}} (excluding this shim directory)
original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}}) original_cmd=$(PATH=$(remove_shim_from_path) command -v {{PACKAGE_MANAGER}})
if [ -n "$original_cmd" ]; then if [ -n "$original_cmd" ]; then

View file

@ -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 This wrapper intercepts {{PACKAGE_MANAGER}} calls for non-interactive environments
REM Remove shim directory from PATH to prevent infinite loops 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%;=%%" call set "CLEAN_PATH=%%PATH:%SHIM_DIR%;=%%"
REM Check if aikido command is available with clean PATH 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 REM If we get here, original command was not found
echo Error: Could not find original {{PACKAGE_MANAGER}} >&2 echo Error: Could not find original {{PACKAGE_MANAGER}} >&2
exit /b 1 exit /b 1
) )

View file

@ -1,24 +1,14 @@
import chalk from "chalk"; import chalk from "chalk";
import { ui } from "../environment/userInteraction.js"; 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 fs from "fs";
import os from "os"; import os from "os";
import path from "path"; 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. * Loops over the detected shells and calls the setup function for each.
@ -31,7 +21,7 @@ export async function setupCi() {
ui.emptyLine(); ui.emptyLine();
const shimsDir = getShimsDir(); const shimsDir = getShimsDir();
const binDir = path.join(os.homedir(), ".safe-chain", "bin"); const binDir = getBinDir();
// Create the shims directory if it doesn't exist // Create the shims directory if it doesn't exist
if (!fs.existsSync(shimsDir)) { if (!fs.existsSync(shimsDir)) {
fs.mkdirSync(shimsDir, { recursive: true }); fs.mkdirSync(shimsDir, { recursive: true });
@ -50,12 +40,7 @@ export async function setupCi() {
*/ */
function createUnixShims(shimsDir) { function createUnixShims(shimsDir) {
// Read the template file // Read the template file
const templatePath = path.resolve( const templatePath = getPathWrapperTemplatePath(import.meta.url, "unix-wrapper.template.sh");
dirname,
"path-wrappers",
"templates",
"unix-wrapper.template.sh"
);
if (!fs.existsSync(templatePath)) { if (!fs.existsSync(templatePath)) {
ui.writeError(`Template file not found: ${templatePath}`); ui.writeError(`Template file not found: ${templatePath}`);
@ -89,12 +74,7 @@ function createUnixShims(shimsDir) {
*/ */
function createWindowsShims(shimsDir) { function createWindowsShims(shimsDir) {
// Read the template file // Read the template file
const templatePath = path.resolve( const templatePath = getPathWrapperTemplatePath(import.meta.url, "windows-wrapper.template.cmd");
dirname,
"path-wrappers",
"templates",
"windows-wrapper.template.cmd"
);
if (!fs.existsSync(templatePath)) { if (!fs.existsSync(templatePath)) {
ui.writeError(`Windows template file not found: ${templatePath}`); ui.writeError(`Windows template file not found: ${templatePath}`);

View file

@ -22,12 +22,12 @@ describe("Setup CI shell integration", () => {
fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true }); fs.mkdirSync(path.join(mockTemplateDir, "path-wrappers", "templates"), { recursive: true });
fs.writeFileSync( fs.writeFileSync(
path.join(mockTemplateDir, "path-wrappers", "templates", "unix-wrapper.template.sh"), 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" "utf-8"
); );
fs.writeFileSync( fs.writeFileSync(
path.join(mockTemplateDir, "path-wrappers", "templates", "windows-wrapper.template.cmd"), 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" "utf-8"
); );
@ -50,7 +50,15 @@ describe("Setup CI shell integration", () => {
{ tool: "yarn", aikidoCommand: "aikido-yarn" }, { tool: "yarn", aikidoCommand: "aikido-yarn" },
], ],
getPackageManagerList: () => "npm, yarn", getPackageManagerList: () => "npm, yarn",
},
});
mock.module("../config/safeChainDir.js", {
namedExports: {
getShimsDir: () => mockShimsDir, 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 // Import setupCi module after mocking
setupCi = (await import("./setup-ci.js")).setupCi; setupCi = (await import("./setup-ci.js")).setupCi;
}); });
@ -119,6 +111,10 @@ describe("Setup CI shell integration", () => {
const npmShimContent = fs.readFileSync(npmShimPath, "utf-8"); const npmShimContent = fs.readFileSync(npmShimPath, "utf-8");
assert.ok(npmShimContent.includes("aikido-npm"), "npm shim should contain aikido-npm"); 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("#!/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 () => { 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("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("@echo off"), "npm.cmd should have Windows batch header");
assert.ok(npmShimContent.includes("%*"), "npm.cmd should use Windows argument passing"); 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 // Verify Unix shims were NOT created
const unixNpmShim = path.join(mockShimsDir, "npm"); const unixNpmShim = path.join(mockShimsDir, "npm");

View file

@ -1,28 +1,10 @@
import chalk from "chalk"; import chalk from "chalk";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.js"; import { detectShells } from "./shellDetection.js";
import { import { knownAikidoTools, getPackageManagerList } from "./helpers.js";
knownAikidoTools, import { getScriptsDir, getStartupScriptSourcePath } from "../config/safeChainDir.js";
getPackageManagerList,
getScriptsDir,
} from "./helpers.js";
import fs from "fs"; import fs from "fs";
import path from "path"; 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. * Loops over the detected shells and calls the setup function for each.
@ -122,8 +104,7 @@ function copyStartupFiles() {
fs.mkdirSync(targetDir, { recursive: true }); fs.mkdirSync(targetDir, { recursive: true });
} }
// Use absolute path for source const sourcePath = getStartupScriptSourcePath(import.meta.url, file);
const sourcePath = path.join(dirname, "startup-scripts", file);
fs.copyFileSync(sourcePath, targetPath); fs.copyFileSync(sourcePath, targetPath);
} }
} }

View file

@ -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 function npx
wrapSafeChainCommand "npx" $argv wrapSafeChainCommand "npx" $argv

View file

@ -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() { function npx() {
wrapSafeChainCommand "npx" "$@" wrapSafeChainCommand "npx" "$@"

View file

@ -2,7 +2,8 @@
# $IsWindows is only available in PowerShell Core 6.0+. If it doesn't exist, assume Windows PowerShell # $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 } $isWindowsPlatform = if (Test-Path variable:IsWindows) { $IsWindows } else { $true }
$pathSeparator = if ($isWindowsPlatform) { ';' } else { ':' } $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" $env:PATH = "$env:PATH$pathSeparator$safeChainBin"
function npx { function npx {

View file

@ -3,8 +3,10 @@ import {
doesExecutableExistOnSystem, doesExecutableExistOnSystem,
removeLinesMatchingPattern, removeLinesMatchingPattern,
} from "../helpers.js"; } from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync, spawnSync } from "child_process"; import { execSync, spawnSync } from "child_process";
import * as os from "os"; import * as os from "os";
import path from "path";
const shellName = "Bash"; const shellName = "Bash";
const executableName = "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( removeLinesMatchingPattern(
startupFile, startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/,
eol eol
); );
@ -44,10 +46,11 @@ function teardown(tools) {
function setup() { function setup() {
const startupFile = getStartupFile(); const startupFile = getStartupFile();
const scriptsDir = getShellScriptsDir();
addLineToFile( addLineToFile(
startupFile, 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 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() { function hasCygpath() {
try { try {
var result = spawnSync("where", ["cygpath"], { shell: executableName }); 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() { function getManualTeardownInstructions() {
const scriptsDir = getShellScriptsDir();
return [ return [
`Remove the following line from your ~/.bashrc file:`, `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`, `Then restart your terminal or run: source ~/.bashrc`,
]; ];
} }
function getManualSetupInstructions() { function getManualSetupInstructions() {
const scriptsDir = getShellScriptsDir();
return [ return [
`Add the following line to your ~/.bashrc file:`, `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`, `Then restart your terminal or run: source ~/.bashrc`,
]; ];
} }

View file

@ -9,6 +9,7 @@ describe("Bash shell integration", () => {
let mockStartupFile; let mockStartupFile;
let bash; let bash;
let windowsCygwinPath = ""; let windowsCygwinPath = "";
let mockScriptsDir = "/test-home/.safe-chain/scripts";
let platform = "linux"; let platform = "linux";
beforeEach(async () => { beforeEach(async () => {
@ -35,6 +36,12 @@ describe("Bash shell integration", () => {
}, },
}); });
mock.module("../../config/safeChainDir.js", {
namedExports: {
getScriptsDir: () => mockScriptsDir,
},
});
// Mock child_process execSync // Mock child_process execSync
mock.module("child_process", { mock.module("child_process", {
namedExports: { namedExports: {
@ -61,6 +68,17 @@ describe("Bash shell integration", () => {
stdout: windowsCygwinPath + "\n", 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 // Reset mocks
mock.reset(); mock.reset();
mockScriptsDir = "/test-home/.safe-chain/scripts";
platform = "linux"; platform = "linux";
}); });
@ -109,7 +128,7 @@ describe("Bash shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( assert.ok(
content.includes( 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"); const content = fs.readFileSync(windowsCygwinPath, "utf-8");
assert.ok( assert.ok(
content.includes( 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 // Setup
bash.setup(tools); bash.setup(tools);
let content = fs.readFileSync(mockStartupFile, "utf-8"); 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 // Teardown
bash.teardown(tools); bash.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8"); content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( 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 = [ const initialContent = [
"#!/bin/bash", "#!/bin/bash",
"alias npm='old-npm'", "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'", "alias ls='ls --color=auto'",
].join("\n"); ].join("\n");
@ -247,7 +283,7 @@ describe("Bash shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm=")); assert.ok(!content.includes("alias npm="));
assert.ok( 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=")); assert.ok(content.includes("alias ls="));
}); });

View file

@ -3,7 +3,9 @@ import {
doesExecutableExistOnSystem, doesExecutableExistOnSystem,
removeLinesMatchingPattern, removeLinesMatchingPattern,
} from "../helpers.js"; } from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import path from "path";
const shellName = "Fish"; const shellName = "Fish";
const executableName = "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( removeLinesMatchingPattern(
startupFile, startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-fish\.fish/, /^source\s+.*init-fish\.fish.*#\s*Safe-chain/,
eol eol
); );
@ -46,7 +48,7 @@ function setup() {
addLineToFile( addLineToFile(
startupFile, 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 eol
); );
@ -69,7 +71,7 @@ function getStartupFile() {
function getManualTeardownInstructions() { function getManualTeardownInstructions() {
return [ return [
`Remove the following line from your ~/.config/fish/config.fish file:`, `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`, `Then restart your terminal or run: source ~/.config/fish/config.fish`,
]; ];
} }
@ -77,7 +79,7 @@ function getManualTeardownInstructions() {
function getManualSetupInstructions() { function getManualSetupInstructions() {
return [ return [
`Add the following line to your ~/.config/fish/config.fish file:`, `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`, `Then restart your terminal or run: source ~/.config/fish/config.fish`,
]; ];
} }

View file

@ -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 child_process execSync
mock.module("child_process", { mock.module("child_process", {
namedExports: { namedExports: {
@ -72,7 +78,7 @@ describe("Fish shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( 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(); fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8"); 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)"); 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 npm 'aikido-npm'",
"alias npx 'aikido-npx'", "alias npx 'aikido-npx'",
"alias yarn 'aikido-yarn'", "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 ls 'ls --color=auto'",
"alias grep 'grep --color=auto'", "alias grep 'grep --color=auto'",
].join("\n"); ].join("\n");
@ -107,7 +113,7 @@ describe("Fish shell integration", () => {
assert.ok(!content.includes("alias npm ")); assert.ok(!content.includes("alias npm "));
assert.ok(!content.includes("alias npx ")); assert.ok(!content.includes("alias npx "));
assert.ok(!content.includes("alias yarn ")); 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 ls "));
assert.ok(content.includes("alias grep ")); assert.ok(content.includes("alias grep "));
}); });
@ -162,12 +168,12 @@ describe("Fish shell integration", () => {
// Setup // Setup
fish.setup(); fish.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8"); 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 // Teardown
fish.teardown(tools); fish.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8"); 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", () => { it("should handle multiple setup calls", () => {
@ -176,7 +182,7 @@ describe("Fish shell integration", () => {
fish.setup(); fish.setup();
const content = fs.readFileSync(mockStartupFile, "utf-8"); 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"); assert.strictEqual(sourceMatches, 1, "Should have exactly one source line after setup-teardown-setup cycle");
}); });
}); });

View file

@ -4,7 +4,9 @@ import {
removeLinesMatchingPattern, removeLinesMatchingPattern,
validatePowerShellExecutionPolicy, validatePowerShellExecutionPolicy,
} from "../helpers.js"; } from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import path from "path";
const shellName = "PowerShell Core"; const shellName = "PowerShell Core";
const executableName = "pwsh"; 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( removeLinesMatchingPattern(
startupFile, startupFile,
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/,
); );
return true; return true;
@ -52,7 +54,7 @@ async function setup() {
addLineToFile( addLineToFile(
startupFile, 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; return true;
@ -74,7 +76,7 @@ function getStartupFile() {
function getManualTeardownInstructions() { function getManualTeardownInstructions() {
return [ return [
`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, `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`, `Then restart your terminal or run: . $PROFILE`,
]; ];
} }
@ -82,7 +84,7 @@ function getManualTeardownInstructions() {
function getManualSetupInstructions() { function getManualSetupInstructions() {
return [ return [
`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, `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`, `Then restart your terminal or run: . $PROFILE`,
]; ];
} }

View file

@ -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 child_process execSync
mock.module("child_process", { mock.module("child_process", {
namedExports: { namedExports: {
@ -83,7 +89,7 @@ describe("PowerShell Core shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( assert.ok(
content.includes( 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", () => { it("should remove init-pwsh.ps1 source line", () => {
const initialContent = [ const initialContent = [
"# PowerShell profile", "# 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 ls Get-ChildItem",
"Set-Alias grep Select-String", "Set-Alias grep Select-String",
].join("\n"); ].join("\n");
@ -105,7 +111,7 @@ describe("PowerShell Core shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( 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 ls "));
assert.ok(content.includes("Set-Alias grep ")); assert.ok(content.includes("Set-Alias grep "));
@ -180,14 +186,14 @@ describe("PowerShell Core shell integration", () => {
await powershell.setup(); await powershell.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8"); let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( assert.ok(
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
); );
// Teardown // Teardown
powershell.teardown(knownAikidoTools); powershell.teardown(knownAikidoTools);
content = fs.readFileSync(mockStartupFile, "utf-8"); content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( 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 content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = ( const sourceMatches = (
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) || content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) ||
[] []
).length; ).length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");

View file

@ -4,7 +4,9 @@ import {
removeLinesMatchingPattern, removeLinesMatchingPattern,
validatePowerShellExecutionPolicy, validatePowerShellExecutionPolicy,
} from "../helpers.js"; } from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import path from "path";
const shellName = "Windows PowerShell"; const shellName = "Windows PowerShell";
const executableName = "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( removeLinesMatchingPattern(
startupFile, startupFile,
/^\.\s+["']?\$HOME[/\\].safe-chain[/\\]scripts[/\\]init-pwsh\.ps1["']?/, /^\.\s+["']?.*init-pwsh\.ps1["']?.*#\s*Safe-chain/,
); );
return true; return true;
@ -52,7 +54,7 @@ async function setup() {
addLineToFile( addLineToFile(
startupFile, 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; return true;
@ -74,7 +76,7 @@ function getStartupFile() {
function getManualTeardownInstructions() { function getManualTeardownInstructions() {
return [ return [
`Remove the following line from your PowerShell profile (run "echo $PROFILE" to find its location):`, `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`, `Then restart your terminal or run: . $PROFILE`,
]; ];
} }
@ -82,7 +84,7 @@ function getManualTeardownInstructions() {
function getManualSetupInstructions() { function getManualSetupInstructions() {
return [ return [
`Add the following line to your PowerShell profile (run "echo $PROFILE" to find its location):`, `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`, `Then restart your terminal or run: . $PROFILE`,
]; ];
} }

View file

@ -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 child_process execSync
mock.module("child_process", { mock.module("child_process", {
namedExports: { namedExports: {
@ -83,7 +89,7 @@ describe("Windows PowerShell shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( assert.ok(
content.includes( 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", () => { it("should remove init-pwsh.ps1 source line", () => {
const initialContent = [ const initialContent = [
"# Windows PowerShell profile", "# 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 ls Get-ChildItem",
"Set-Alias grep Select-String", "Set-Alias grep Select-String",
].join("\n"); ].join("\n");
@ -105,7 +111,7 @@ describe("Windows PowerShell shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( 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 ls "));
assert.ok(content.includes("Set-Alias grep ")); assert.ok(content.includes("Set-Alias grep "));
@ -180,14 +186,14 @@ describe("Windows PowerShell shell integration", () => {
await windowsPowershell.setup(); await windowsPowershell.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8"); let content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( assert.ok(
content.includes('. "$HOME\\.safe-chain\\scripts\\init-pwsh.ps1"'), content.includes('. "/test-home/.safe-chain/scripts/init-pwsh.ps1"'),
); );
// Teardown // Teardown
windowsPowershell.teardown(knownAikidoTools); windowsPowershell.teardown(knownAikidoTools);
content = fs.readFileSync(mockStartupFile, "utf-8"); content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( 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 content = fs.readFileSync(mockStartupFile, "utf-8");
const sourceMatches = ( const sourceMatches = (
content.match(/\. "\$HOME\\.safe-chain\\scripts\\init-pwsh\.ps1"/g) || content.match(/\. "\/test-home\/\.safe-chain\/scripts\/init-pwsh\.ps1"/g) ||
[] []
).length; ).length;
assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines"); assert.strictEqual(sourceMatches, 1, "Should not duplicate source lines");

View file

@ -3,7 +3,9 @@ import {
doesExecutableExistOnSystem, doesExecutableExistOnSystem,
removeLinesMatchingPattern, removeLinesMatchingPattern,
} from "../helpers.js"; } from "../helpers.js";
import { getScriptsDir } from "../../config/safeChainDir.js";
import { execSync } from "child_process"; import { execSync } from "child_process";
import path from "path";
const shellName = "Zsh"; const shellName = "Zsh";
const executableName = "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( removeLinesMatchingPattern(
startupFile, startupFile,
/^source\s+~\/\.safe-chain\/scripts\/init-posix\.sh/, /^source\s+.*init-posix\.sh.*#\s*Safe-chain/,
eol eol
); );
@ -46,7 +48,7 @@ function setup() {
addLineToFile( addLineToFile(
startupFile, 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 eol
); );
@ -69,7 +71,7 @@ function getStartupFile() {
function getManualTeardownInstructions() { function getManualTeardownInstructions() {
return [ return [
`Remove the following line from your ~/.zshrc file:`, `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`, `Then restart your terminal or run: source ~/.zshrc`,
]; ];
} }
@ -77,7 +79,7 @@ function getManualTeardownInstructions() {
function getManualSetupInstructions() { function getManualSetupInstructions() {
return [ return [
`Add the following line to your ~/.zshrc file:`, `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`, `Then restart your terminal or run: source ~/.zshrc`,
]; ];
} }

View file

@ -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 child_process execSync
mock.module("child_process", { mock.module("child_process", {
namedExports: { namedExports: {
@ -73,7 +79,7 @@ describe("Zsh shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( assert.ok(
content.includes( 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); assert.strictEqual(result, true);
const content = fs.readFileSync(mockStartupFile, "utf-8"); 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", () => { it("should remove zsh initialization script source line", () => {
const initialContent = [ const initialContent = [
"#!/bin/zsh", "#!/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'", "alias ls='ls --color=auto'",
].join("\n"); ].join("\n");
@ -125,7 +131,7 @@ describe("Zsh shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( 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=")); assert.ok(content.includes("alias ls="));
}); });
@ -180,13 +186,13 @@ describe("Zsh shell integration", () => {
// Setup // Setup
zsh.setup(); zsh.setup();
let content = fs.readFileSync(mockStartupFile, "utf-8"); 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 // Teardown
zsh.teardown(tools); zsh.teardown(tools);
content = fs.readFileSync(mockStartupFile, "utf-8"); content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok( 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 = [ const initialContent = [
"#!/bin/zsh", "#!/bin/zsh",
"alias npm='old-npm'", "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'", "alias ls='ls --color=auto'",
].join("\n"); ].join("\n");
@ -218,7 +224,7 @@ describe("Zsh shell integration", () => {
const content = fs.readFileSync(mockStartupFile, "utf-8"); const content = fs.readFileSync(mockStartupFile, "utf-8");
assert.ok(!content.includes("alias npm=")); assert.ok(!content.includes("alias npm="));
assert.ok( 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=")); assert.ok(content.includes("alias ls="));
}); });

View file

@ -1,7 +1,8 @@
import chalk from "chalk"; import chalk from "chalk";
import { ui } from "../environment/userInteraction.js"; import { ui } from "../environment/userInteraction.js";
import { detectShells } from "./shellDetection.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"; import fs from "fs";
/** /**
@ -109,4 +110,5 @@ export async function teardownDirectories() {
); );
} }
} }
} }

View file

@ -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( let ptyProcess = pty.spawn(
"docker", "docker",
["exec", "-it", this.containerName, shell], execArgs,
{ {
name: "xterm-color", name: "xterm-color",
cols: 80, cols: 80,