diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 43a77cf3d..666ac34e7 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -184,6 +184,10 @@ jobs: with: fetch-depth: 1 + - name: "Install shell integration test dependencies" + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install --yes fish zsh + - name: "install.sh smoke test" if: runner.os != 'Windows' shell: bash @@ -194,6 +198,11 @@ jobs: shell: pwsh run: ./scripts/smoke/install-smoke.ps1 + - name: "install.ps1 Windows PowerShell 5.1 smoke test" + if: runner.os == 'Windows' + shell: powershell + run: ./scripts/smoke/install-smoke.ps1 + semver-conformance: name: SemVer Conformance runs-on: ubuntu-latest diff --git a/README.md b/README.md index fdff5673b..b98f73e20 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,14 @@ curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --channel beta # Pin a specific version (e.g. a prerelease) NETCLAW_VERSION=0.17.1 curl -sSL https://releases.netclaw.dev/install.sh | bash + +# Install without modifying your shell profile +curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --skip-shell ``` +By default, the Unix installer updates the detected Bash, zsh, or fish startup +configuration so new shells include Netclaw on `PATH`. + **macOS** (Apple Silicon — M1 or later — installs CLI + daemon to `~/.netclaw/bin`): ```bash @@ -119,8 +125,9 @@ available on macOS ([#1015](https://github.com/netclaw-dev/netclaw/issues/1015)) iwr -useb https://releases.netclaw.dev/install.ps1 | iex ``` -The `-Component cli|daemon`, `-Channel beta`, and `-Version` options work the same -way as their Linux counterparts (download the script and run it with the flag). +The installer adds Netclaw to your User PATH. The `-Component cli|daemon`, +`-Channel beta`, `-Version`, and `-SkipShell` options work the same way as their +Linux counterparts (download the script and run it with the flag). **Docker** (multi-arch: amd64/arm64): diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 5db509d7f..3633a3d86 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -7,6 +7,7 @@ # .\install.ps1 -InstallDir C:\tools\netclaw # .\install.ps1 -Channel beta # Opt into prereleases # .\install.ps1 -DryRun +# .\install.ps1 -SkipShell # Don't modify PATH # # -Channel beta installs the newest prerelease (or latest stable if no prerelease # exists). -Version pins an exact version and overrides -Channel (e.g. 0.19.0-beta.1). @@ -24,11 +25,37 @@ param( [string]$Channel = "stable", # Resolve and report what would be installed, but install nothing. - [switch]$DryRun + [switch]$DryRun, + + # Skip automatic PATH modification. + [switch]$SkipShell ) $ErrorActionPreference = "Stop" +function Remove-TrailingDirectorySeparators { + param([string]$Path) + + if ([string]::IsNullOrEmpty($Path)) { + return $Path + } + + $root = [System.IO.Path]::GetPathRoot($Path) + if ($Path -eq $root) { + return $Path + } + + $separators = [char[]]@( + [System.IO.Path]::DirectorySeparatorChar, + [System.IO.Path]::AltDirectorySeparatorChar) + $trimmed = $Path.TrimEnd($separators) + if ([string]::IsNullOrEmpty($trimmed) -and -not [string]::IsNullOrEmpty($root)) { + return $root + } + + return $trimmed +} + function Invoke-DownloadWithProgress { param( [string]$Uri, @@ -94,6 +121,11 @@ if (-not $InstallDir) { $InstallDir = $DefaultInstallDir } +$InstallDir = [System.IO.Path]::GetFullPath($InstallDir) +if ($InstallDir.Contains(';') -or $InstallDir.Contains("`r") -or $InstallDir.Contains("`n")) { + throw "InstallDir cannot contain semicolons, carriage returns, or newlines when used on PATH." +} + Write-Host "Netclaw installer" Write-Host " Platform: win-x64" Write-Host " Install dir: $InstallDir" @@ -256,21 +288,126 @@ try { } } - # Check PATH and offer to add if missing + # ── Add to PATH ── Write-Host "" - $userPath = [Environment]::GetEnvironmentVariable("PATH", "User") - $pathEntries = $userPath -split ';' | ForEach-Object { $_.TrimEnd('\') } - $installDirNormalized = $InstallDir.TrimEnd('\') - if ($pathEntries -contains $installDirNormalized) { - Write-Host "Installation complete! netclaw is already on your PATH." + if (-not $SkipShell) { + $installDirNormalized = Remove-TrailingDirectorySeparators $InstallDir + + # Read the raw registry value so an existing REG_EXPAND_SZ PATH keeps both + # its %VAR% references and its registry type when we prepend Netclaw. + # HKCU is writable by the current user and does not require elevation. + # CreateSubKey opens the normal existing key and also supports minimal + # profiles where the per-user Environment key has not been created yet. + $userEnvironmentKey = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") + if ($null -eq $userEnvironmentKey) { + throw "Cannot create or open the current user's Environment registry key for PATH update." + } + + try { + $pathValueExists = $userEnvironmentKey.GetValueNames() -contains "Path" + $userPath = if ($pathValueExists) { + $userEnvironmentKey.GetValue( + "Path", + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + } else { + "" + } + + if ($null -ne $userPath -and $userPath -isnot [string]) { + throw "The current user's PATH registry value is not a string." + } + + $userPathKind = if ($pathValueExists) { + $userEnvironmentKey.GetValueKind("Path") + } else { + [Microsoft.Win32.RegistryValueKind]::String + } + if ($userPathKind -notin @( + [Microsoft.Win32.RegistryValueKind]::String, + [Microsoft.Win32.RegistryValueKind]::ExpandString)) { + throw "The current user's PATH registry value has unsupported type $userPathKind." + } + + if ($userPathKind -eq [Microsoft.Win32.RegistryValueKind]::ExpandString ` + -and $installDirNormalized.Contains('%')) { + throw "InstallDir containing '%' cannot be safely added to an expandable User PATH. Choose a directory without '%' or rerun with -SkipShell." + } + + $userPathEntries = if ([string]::IsNullOrEmpty($userPath)) { @() } else { + $userPath -split ';' | + Where-Object { -not [string]::IsNullOrEmpty($_) } | + ForEach-Object { + $expandedEntry = [Environment]::ExpandEnvironmentVariables($_) + Remove-TrailingDirectorySeparators $expandedEntry + } + } + + $userPathChanged = $false + if ($userPathEntries -notcontains $installDirNormalized) { + $newUserPath = if ([string]::IsNullOrEmpty($userPath)) { + $installDirNormalized + } else { + "$installDirNormalized;$userPath" + } + + if ($newUserPath.Length -gt 32700) { + Write-Warning "User PATH is near its 32,767 character limit ($($newUserPath.Length) chars)." + Write-Host "Please manually add $InstallDir to your User PATH." + } else { + $userEnvironmentKey.SetValue("Path", $newUserPath, $userPathKind) + $userPathChanged = $true + } + } + } finally { + $userEnvironmentKey.Dispose() + } + + $processPath = $env:PATH + $processPathEntries = if ([string]::IsNullOrEmpty($processPath)) { @() } else { + $processPath -split ';' | + Where-Object { -not [string]::IsNullOrEmpty($_) } | + ForEach-Object { Remove-TrailingDirectorySeparators $_ } + } + if ($processPathEntries -notcontains $installDirNormalized) { + $env:PATH = if ([string]::IsNullOrEmpty($processPath)) { + $installDirNormalized + } else { + "$installDirNormalized;$processPath" + } + } + + if ($userPathChanged) { + if (-not ("NetclawInstaller.NativeMethods" -as [type])) { + Add-Type -Namespace NetclawInstaller -Name NativeMethods -MemberDefinition @' + [DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] + public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg, + UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); +'@ + } + + $broadcastOutput = [UIntPtr]::Zero + $broadcastResult = [NetclawInstaller.NativeMethods]::SendMessageTimeout( + [IntPtr]0xFFFF, 0x001A, [UIntPtr]::Zero, "Environment", 2, 1000, [ref]$broadcastOutput) + if ($broadcastResult -eq [IntPtr]::Zero) { + Write-Warning "User PATH was updated, but Windows did not acknowledge the environment-change notification. New terminals may require sign-out or restart." + } + } + + if ($userPathEntries -contains $installDirNormalized) { + Write-Host "Installation complete! netclaw is already on your User PATH." + } elseif ($userPathChanged) { + Write-Host "Installation complete! netclaw was added to your User PATH." + } else { + Write-Host "Installation complete! netclaw is on PATH for this terminal only." + } } else { - Write-Host "Installation complete!" + Write-Host "Installation complete! (PATH modification skipped)" Write-Host "" - Write-Host "Add Netclaw to your PATH by running:" + Write-Host "Add this directory to your User PATH using Windows Environment Variables settings:" Write-Host "" - Write-Host " `$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User')" - Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$InstallDir;`$userPath`", 'User')" + Write-Host " $InstallDir" Write-Host "" Write-Host "Then restart your terminal." } diff --git a/scripts/install.sh b/scripts/install.sh index bff6252a6..7f9b4ca74 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -6,6 +6,7 @@ # curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- cli # CLI only # curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- daemon # Daemon only # curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --channel beta # Opt into prereleases +# curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --skip-shell # Don't modify shell profile # INSTALL_DIR=/opt/netclaw curl -sSL https://releases.netclaw.dev/install.sh | bash # # Arguments: @@ -13,6 +14,7 @@ # --channel stable|beta — Release channel (default: stable). 'beta' installs the # newest prerelease (or latest stable if no prerelease exists). # --dry-run — Resolve and report what would happen; install nothing. +# --skip-shell — Skip automatic shell profile modification. # # Environment variables: # INSTALL_DIR — Install directory (default: ~/.netclaw/bin) @@ -36,9 +38,11 @@ COMPONENT="all" # "all", "cli", or "daemon" DRY_RUN=false # --dry-run: resolve and report what would happen, install nothing CHANNEL="stable" # release channel: "stable" (default) or "beta" (opt into prereleases) CHANNEL_EXPLICIT=false # true when --channel was explicitly passed +SKIP_SHELL=false # --skip-shell: don't modify shell profile while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=true; shift ;; + --skip-shell) SKIP_SHELL=true; shift ;; --channel) if [ $# -lt 2 ]; then echo "Error: --channel requires a value (stable|beta)" >&2; exit 1 @@ -46,7 +50,7 @@ while [ $# -gt 0 ]; do CHANNEL="$2"; CHANNEL_EXPLICIT=true; shift 2 ;; --channel=*) CHANNEL="${1#*=}"; CHANNEL_EXPLICIT=true; shift ;; all|cli|daemon) COMPONENT="$1"; shift ;; - *) echo "Usage: install.sh [all|cli|daemon] [--channel stable|beta] [--dry-run]" >&2; exit 1 ;; + *) echo "Usage: install.sh [all|cli|daemon] [--channel stable|beta] [--dry-run] [--skip-shell]" >&2; exit 1 ;; esac done @@ -133,11 +137,61 @@ json_field() { fi } +json_asset_field() { + local json="$1" version="$2" component="$3" rid="$4" field="$5" + + # Release manifests contain flat release and asset objects. Splitting object + # boundaries lets POSIX awk select an asset without depending on property order + # or GNU-only regular-expression extensions. + printf '%s\n' "$json" | tr '{' '\n' | tr '}' '\n' | awk \ + -v wanted_version="$version" \ + -v wanted_component="$component" \ + -v wanted_rid="$rid" \ + -v wanted_field="$field" ' + function string_field(record, name, value) { + if (index(record, "\"" name "\"") == 0) { + return "" + } + + value = record + sub(".*\"" name "\"[[:space:]]*:[[:space:]]*\"", "", value) + sub("\".*", "", value) + return value + } + + { + release_version = string_field($0, "version") + if (release_version != "") { + current_version = release_version + } + + if (current_version == wanted_version && + string_field($0, "component") == wanted_component && + string_field($0, "rid") == wanted_rid) { + print string_field($0, wanted_field) + exit + } + } + ' +} + +validate_install_dir_for_path() { + local install_dir="$1" + + # PATH uses ':' as its entry separator, and startup files are line-oriented. + # These names cannot be represented without changing their meaning. + if [[ "$install_dir" == *:* || "$install_dir" == *$'\n'* || "$install_dir" == *$'\r'* ]]; then + echo "Error: INSTALL_DIR cannot contain ':', carriage returns, or newlines when used on PATH." >&2 + return 1 + fi +} + # ── Main ── check_deps RID=$(detect_platform) INSTALL_DIR="${INSTALL_DIR:-$HOME/.netclaw/bin}" +validate_install_dir_for_path "$INSTALL_DIR" echo "Netclaw installer" echo " Platform: $RID" @@ -191,16 +245,8 @@ download_component() { url=$(echo "$MANIFEST" | jq -r ".releases[] | select(.version==\"$VERSION\") | .assets[] | select(.component==\"$component\" and .rid==\"$RID\") | .url") sha256=$(echo "$MANIFEST" | jq -r ".releases[] | select(.version==\"$VERSION\") | .assets[] | select(.component==\"$component\" and .rid==\"$RID\") | .sha256") else - # Fallback: extract URL and sha256 using grep (fragile but works for well-formed JSON) - # Find the block for this component+rid - local block - block=$(echo "$MANIFEST" | tr '\n' ' ' | grep -oP "\"component\"\\s*:\\s*\"${component}\"[^}]*\"rid\"\\s*:\\s*\"${RID}\"[^}]*}" | head -1) - if [ -z "$block" ]; then - # Try reversed order - block=$(echo "$MANIFEST" | tr '\n' ' ' | grep -oP "\"rid\"\\s*:\\s*\"${RID}\"[^}]*\"component\"\\s*:\\s*\"${component}\"[^}]*}" | head -1) - fi - url=$(echo "$block" | grep -oP '"url"\s*:\s*"\K[^"]+') - sha256=$(echo "$block" | grep -oP '"sha256"\s*:\s*"\K[^"]+') + url=$(json_asset_field "$MANIFEST" "$VERSION" "$component" "$RID" "url") + sha256=$(json_asset_field "$MANIFEST" "$VERSION" "$component" "$RID" "sha256") fi if [ -z "$url" ] || [ "$url" = "null" ]; then @@ -246,12 +292,19 @@ download_component() { return 1 fi - mkdir -p "$INSTALL_DIR" cp "$binary_path" "$INSTALL_DIR/$binary_name" chmod +x "$INSTALL_DIR/$binary_name" echo " Installed $binary_name to $INSTALL_DIR/" } +if [ "$DRY_RUN" = false ]; then + # Resolve symlinks before installing so the exact path persisted into shell + # startup files is the same path that passed delimiter validation. + mkdir -p "$INSTALL_DIR" + INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd -P)" + validate_install_dir_for_path "$INSTALL_DIR" +fi + # Download requested components SUCCESS=true if [[ "$COMPONENT" == "all" || "$COMPONENT" == "cli" ]]; then @@ -304,20 +357,178 @@ if [ "$CHANNEL_EXPLICIT" = true ]; then fi fi -# PATH instructions -echo "" -if echo "$PATH" | tr ':' '\n' | grep -qx "$INSTALL_DIR"; then - echo "Installation complete! netclaw is already on your PATH." -else - echo "Installation complete!" +# ── Shell integration ───────────────────────────────────────────────────── +# Bash and zsh source a small POSIX env file. Fish gets native syntax in its +# dedicated conf.d file; fish cannot source POSIX `case ... esac` syntax. +ENV_SCRIPT="$HOME/.netclaw/env" + +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +INSTALL_DIR_QUOTED="$(shell_quote "$INSTALL_DIR")" +ENV_SCRIPT_QUOTED="$(shell_quote "$ENV_SCRIPT")" +SOURCE_LINE=". $ENV_SCRIPT_QUOTED" +MANUAL_PATH_LINE="export PATH=$INSTALL_DIR_QUOTED\${PATH:+:\"\$PATH\"}" + +detect_shell() { + # $SHELL is inherited from the parent login shell — it reflects the user's + # configured shell even when this script is piped via `curl | bash`. + local shell_name + shell_name="$(basename "${SHELL:-/bin/sh}")" + echo "$shell_name" +} + +get_rc_file() { + local shell_name="$1" shell_path="$2" + local os + os="$(uname -s | tr '[:upper:]' '[:lower:]')" + + case "$shell_name" in + zsh) + local effective_zdotdir + # ZDOTDIR is often assigned without export in ~/.zshenv, so ask zsh + # for the value it actually uses rather than relying on Bash's env. + effective_zdotdir="$("$shell_path" -c "printf '%s' \"\${ZDOTDIR:-\$HOME}\"")" || return 1 + if [[ -z "$effective_zdotdir" || "$effective_zdotdir" != /* || \ + "$effective_zdotdir" == *$'\n'* || "$effective_zdotdir" == *$'\r'* ]]; then + return 1 + fi + echo "$effective_zdotdir/.zshrc" + ;; + bash) + if [ "$os" = "darwin" ]; then + # A login shell reads only the first existing file in this list. + if [ -f "$HOME/.bash_profile" ]; then + echo "$HOME/.bash_profile" + elif [ -f "$HOME/.bash_login" ]; then + echo "$HOME/.bash_login" + else + echo "$HOME/.profile" + fi + else + echo "$HOME/.bashrc" + fi + ;; + *) + echo "" + ;; + esac +} + +write_posix_env_script() { + mkdir -p "$(dirname "$ENV_SCRIPT")" + cat > "$ENV_SCRIPT" </dev/null; then + echo " Shell profile '$rc_file' already sources netclaw." + return 0 + fi + + if [ -s "$rc_file" ] && [ "$(tail -c1 "$rc_file" | wc -l)" -eq 0 ]; then + echo "" >> "$rc_file" + fi + + { + echo "# netclaw shell setup" + echo "$SOURCE_LINE" + } >> "$rc_file" + + echo " Modified '$rc_file' to add netclaw to PATH." +} + +write_fish_config() { + local fish_config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d" + local fish_config="$fish_config_dir/netclaw.fish" + mkdir -p "$fish_config_dir" + cat > "$fish_config" < checksum -> extract -> install path, plus -DryRun. # # Usage: pwsh -File scripts/smoke/install-smoke.ps1 -# Requires: PowerShell 7+, python (for the local HTTP server). +# powershell.exe -File scripts/smoke/install-smoke.ps1 +# Requires: PowerShell 5.1+ and python (for the local HTTP server). Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot ".." "..")).Path -$InstallPs1 = Join-Path $RepoRoot "scripts" "install.ps1" +$PowerShellExecutable = if ($PSVersionTable.PSEdition -eq "Desktop") { + (Get-Command powershell.exe).Source +} else { + (Get-Command pwsh).Source +} + +$RepoRoot = (Resolve-Path (Join-Path (Join-Path $PSScriptRoot "..") "..")).Path +$InstallPs1 = Join-Path (Join-Path $RepoRoot "scripts") "install.ps1" $Version = "0.0.0" # stable -> manifest.latest $BetaVersion = "0.0.1-beta1" # prerelease -> manifest.latestPrerelease $Rid = "win-x64" @@ -22,9 +29,68 @@ $script:Fail = 0 function Pass([string]$m) { Write-Host "PASS: $m"; $script:Pass++ } function Fail([string]$m) { Write-Host "FAIL: $m"; $script:Fail++ } +function Invoke-CapturedPowerShell { + param([string[]]$Arguments) + + # Windows PowerShell 5.1 promotes a native child's stderr to an error record. + # Rejection tests need to inspect that output and exit code without aborting. + $previousErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = & $PowerShellExecutable @Arguments 2>&1 | Out-String + [PSCustomObject]@{ Output = $output; ExitCode = $LASTEXITCODE } + } finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + +function Get-UserPathRegistryState { + $key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey("Environment", $false) + if ($null -eq $key) { throw "Cannot open the current user's Environment registry key." } + try { + $exists = $key.GetValueNames() -contains "Path" + [PSCustomObject]@{ + Exists = $exists + Value = if ($exists) { + $key.GetValue( + "Path", + $null, + [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + } else { + $null + } + Kind = if ($exists) { $key.GetValueKind("Path") } else { $null } + } + } finally { + $key.Dispose() + } +} + +function Set-UserPathRegistryState { + param( + [bool]$Exists, + [AllowNull()][string]$Value, + [AllowNull()][Microsoft.Win32.RegistryValueKind]$Kind + ) + + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey("Environment") + if ($null -eq $key) { throw "Cannot create or open the current user's Environment registry key for update." } + try { + if ($Exists) { + $key.SetValue("Path", $Value, $Kind) + } else { + $key.DeleteValue("Path", $false) + } + } finally { + $key.Dispose() + } +} + $Work = Join-Path ([System.IO.Path]::GetTempPath()) ("netclaw-install-smoke-" + [Guid]::NewGuid().ToString('N')) $Serve = Join-Path $Work "serve" $BinDir = Join-Path $Work "bin" +$OriginalUserPath = Get-UserPathRegistryState +$OriginalProcessPath = $env:PATH New-Item -ItemType Directory -Path $Serve, $BinDir -Force | Out-Null $ServerProc = $null @@ -74,7 +140,9 @@ try { latestPrerelease = $BetaVersion releases = @($betaEntry, $stableEntry) } - $manifest | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $Serve "manifest.json") -Encoding utf8 + # The fixture is ASCII-only. Windows PowerShell 5.1 writes a BOM for + # -Encoding UTF8 while PowerShell 7 does not, so use an identical encoding. + $manifest | ConvertTo-Json -Depth 8 | Set-Content -Path (Join-Path $Serve "manifest.json") -Encoding ascii # 4. Serve the manifest + archives from localhost $python = Get-Command python3 -ErrorAction SilentlyContinue @@ -103,7 +171,7 @@ try { # 5. Dry-run check - resolves assets, installs nothing Write-Host "=== dry run ===" $dryDir = Join-Path $Work "dryrun-none" - $dryOut = & pwsh -NoProfile -File $InstallPs1 -InstallDir $dryDir -DryRun 2>&1 | Out-String + $dryOut = & $PowerShellExecutable -NoProfile -File $InstallPs1 -InstallDir $dryDir -DryRun 2>&1 | Out-String Write-Host ($dryOut.TrimEnd()) if ($LASTEXITCODE -eq 0 ` -and $dryOut -match 'DRY RUN: would install netclaw ' ` @@ -121,14 +189,21 @@ try { # 6. Real install of the stand-in archives Write-Host "" Write-Host "=== real install ===" - $installDir = Join-Path $Work "installed" - $installOut = & pwsh -NoProfile -File $InstallPs1 -InstallDir $installDir 2>&1 | Out-String - Write-Host ($installOut.TrimEnd()) - if ($LASTEXITCODE -eq 0) { - Pass "install: exited 0" + + $invalidResult = Invoke-CapturedPowerShell -Arguments @( + "-NoProfile", "-File", $InstallPs1, + "-InstallDir", (Join-Path $Work "invalid;path"), "-DryRun") + if ($invalidResult.ExitCode -ne 0 -and $invalidResult.Output -match "cannot contain semicolons") { + Pass "PATH: unrepresentable Windows install directory rejected" } else { - Fail "install: exited $LASTEXITCODE" + Fail "PATH: Windows install directory containing ';' was accepted" } + + $installDir = Join-Path $Work "installed" + $existingUserEntry = Join-Path $Work "existing-user-bin" + Set-UserPathRegistryState $true $existingUserEntry ([Microsoft.Win32.RegistryValueKind]::String) + $installOut = & $InstallPs1 -InstallDir $installDir *>&1 | Out-String + Write-Host ($installOut.TrimEnd()) foreach ($name in @("netclaw", "netclawd")) { $exe = Join-Path $installDir "$name.exe" if ((Test-Path $exe) -and ((Get-Item $exe).Length -gt 0)) { @@ -138,20 +213,125 @@ try { } } - # 7. Verify PATH instruction uses User scope correctly (issue #1072) - # The printed instruction must NOT use $env:PATH (which merges Machine+User - # and corrupts the User PATH when written back). It must read User scope. + # 7. Verify the real installer changed User PATH without replacing the + # current process's inherited Machine PATH entries. + Write-Host "" + Write-Host "=== PATH automation ===" + $persistedPath = Get-UserPathRegistryState + $persistedEntries = @($persistedPath.Value -split ';') + if ($persistedEntries[0] -eq $installDir ` + -and $persistedEntries -contains $existingUserEntry ` + -and @($persistedEntries | Where-Object { $_ -eq $installDir }).Count -eq 1) { + Pass "PATH: install directory prepended once and existing User PATH preserved" + } else { + Fail "PATH: persisted User PATH has unexpected contents" + } + + $originalProcessEntries = @($OriginalProcessPath -split ';' | Where-Object { $_ }) + $currentProcessEntries = @($env:PATH -split ';' | Where-Object { $_ }) + $missingProcessEntries = @($originalProcessEntries | Where-Object { $currentProcessEntries -notcontains $_ }) + if ($currentProcessEntries[0] -eq $installDir ` + -and @($currentProcessEntries | Where-Object { $_ -eq $installDir }).Count -eq 1 ` + -and $missingProcessEntries.Count -eq 0) { + Pass "PATH: current process prepended once and inherited entries preserved" + } else { + Fail "PATH: current process lost inherited entries or contains duplicates" + } + + # A persisted entry must still repair a stale current process, and a + # trailing separator must not create a duplicate User PATH entry. + $env:PATH = $OriginalProcessPath + $userPathBeforeRepeat = Get-UserPathRegistryState + & $InstallPs1 -InstallDir "$installDir\" *>&1 | Out-Null + $userPathAfterRepeat = Get-UserPathRegistryState + $repeatProcessEntries = @($env:PATH -split ';' | Where-Object { $_ }) + if ($userPathAfterRepeat.Value -eq $userPathBeforeRepeat.Value ` + -and $userPathAfterRepeat.Kind -eq $userPathBeforeRepeat.Kind ` + -and $repeatProcessEntries[0] -eq $installDir ` + -and @($repeatProcessEntries | Where-Object { $_ -eq $installDir }).Count -eq 1) { + Pass "PATH: repeat install is idempotent and repairs current process" + } else { + Fail "PATH: repeat install changed User PATH or duplicated process entry" + } + + $env:NETCLAW_SMOKE_INSTALL_DIR = $installDir + $expandedUserPath = "%NETCLAW_SMOKE_INSTALL_DIR%;$existingUserEntry" + Set-UserPathRegistryState $true $expandedUserPath ([Microsoft.Win32.RegistryValueKind]::ExpandString) + $env:PATH = $OriginalProcessPath + & $InstallPs1 -InstallDir $installDir *>&1 | Out-Null + $expandedUserPathAfter = Get-UserPathRegistryState + if ($expandedUserPathAfter.Value -eq $expandedUserPath ` + -and $expandedUserPathAfter.Kind -eq [Microsoft.Win32.RegistryValueKind]::ExpandString) { + Pass "PATH: expandable User entry keeps its raw text and REG_EXPAND_SZ type" + } else { + Fail "PATH: expandable User entry or its registry type was rewritten" + } + + $literalPercentInstall = Join-Path $Work "%NETCLAW_LITERAL%\bin" + Set-UserPathRegistryState $true $existingUserEntry ([Microsoft.Win32.RegistryValueKind]::String) + $literalPercentOut = & $PowerShellExecutable -NoProfile -File $InstallPs1 ` + -InstallDir $literalPercentInstall 2>&1 | Out-String + $literalPercentState = Get-UserPathRegistryState + if ($LASTEXITCODE -eq 0 ` + -and $literalPercentState.Kind -eq [Microsoft.Win32.RegistryValueKind]::String ` + -and @($literalPercentState.Value -split ';')[0] -eq $literalPercentInstall) { + Pass "PATH: literal percent is preserved in a non-expanding User PATH" + } else { + Fail "PATH: literal percent was not preserved in a non-expanding User PATH" + Write-Host ($literalPercentOut.TrimEnd()) + } + + $expandablePath = "%SystemRoot%\System32" + $unsafePercentInstall = Join-Path $Work "%TEMP%\netclaw" + Set-UserPathRegistryState $true $expandablePath ([Microsoft.Win32.RegistryValueKind]::ExpandString) + $expandableStateBefore = Get-UserPathRegistryState + $unsafePercentResult = Invoke-CapturedPowerShell -Arguments @( + "-NoProfile", "-File", $InstallPs1, "-InstallDir", $unsafePercentInstall) + $expandableStateAfter = Get-UserPathRegistryState + if ($unsafePercentResult.ExitCode -ne 0 ` + -and $unsafePercentResult.Output -match "cannot be safely added to an expandable User PATH" ` + -and $expandableStateAfter.Value -eq $expandableStateBefore.Value ` + -and $expandableStateAfter.Kind -eq $expandableStateBefore.Kind) { + Pass "PATH: literal percent is rejected before mutating an expandable User PATH" + } else { + Fail "PATH: literal percent corrupted or changed an expandable User PATH" + } + + $env:PATH = "" + & $InstallPs1 -InstallDir $installDir *>&1 | Out-Null + if ($env:PATH -eq $installDir) { + Pass "PATH: empty process PATH does not create an empty entry" + } else { + Fail "PATH: empty process PATH produced unexpected contents" + } + $env:PATH = $OriginalProcessPath + + # 7c. Test -SkipShell flag Write-Host "" - Write-Host "=== PATH instruction check ===" - if ($installOut -match '\$env:PATH') { - Fail "PATH instruction: uses `$env:PATH (corrupts User PATH by merging Machine entries)" + Write-Host "=== -SkipShell flag ===" + $skipDir = Join-Path $Work "skip-install's" + $skipUserPathBefore = Get-UserPathRegistryState + $skipProcessPathBefore = $env:PATH + $skipOut = & $PowerShellExecutable -NoProfile -File $InstallPs1 -InstallDir $skipDir -SkipShell 2>&1 | Out-String + if ($LASTEXITCODE -eq 0) { + Pass "-SkipShell: install completes without error" + } else { + Fail "-SkipShell: install failed (exit=$LASTEXITCODE)" + } + if ($skipOut -match "PATH modification skipped") { + Pass "-SkipShell: output mentions PATH modification skipped" } else { - Pass "PATH instruction: does not use `$env:PATH" + Fail "-SkipShell: missing 'skipped' message" } - if ($installOut -match "GetEnvironmentVariable\('PATH',\s*'User'\)") { - Pass "PATH instruction: reads from User scope" + $skipUserPathAfter = Get-UserPathRegistryState + if ($skipUserPathAfter.Value -eq $skipUserPathBefore.Value ` + -and $skipUserPathAfter.Kind -eq $skipUserPathBefore.Kind ` + -and $env:PATH -eq $skipProcessPathBefore ` + -and $skipOut -match [regex]::Escape("Add this directory to your User PATH") ` + -and $skipOut -match [regex]::Escape($skipDir)) { + Pass "-SkipShell: PATH is unchanged and manual guidance handles a literal install directory" } else { - Fail "PATH instruction: should read from User scope with GetEnvironmentVariable('PATH', 'User')" + Fail "-SkipShell: changed PATH or printed an unusable manual command" } # 8. Release channel resolution (dry-run) @@ -160,7 +340,7 @@ try { $shouldNotExist = Join-Path $Work "should-not-exist" function Assert-Resolves([string]$desc, [string]$want, [string[]]$extraArgs) { - $out = & pwsh -NoProfile -File $InstallPs1 -InstallDir $shouldNotExist -DryRun @extraArgs 2>&1 | Out-String + $out = & $PowerShellExecutable -NoProfile -File $InstallPs1 -InstallDir $shouldNotExist -DryRun @extraArgs 2>&1 | Out-String $pattern = "(?m)^\s+Version:\s+$([regex]::Escape($want))\s*$" if ($LASTEXITCODE -eq 0 -and $out -match $pattern) { Pass "channel: $desc -> $want" @@ -176,8 +356,10 @@ try { Assert-Resolves "-Version pin overrides -Channel" $BetaVersion @("-Channel", "stable", "-Version", $BetaVersion) # An unknown channel must be rejected by the ValidateSet, not silently default. - & pwsh -NoProfile -File $InstallPs1 -InstallDir $shouldNotExist -DryRun -Channel bogus 2>&1 | Out-Null - if ($LASTEXITCODE -ne 0) { + $invalidChannelResult = Invoke-CapturedPowerShell -Arguments @( + "-NoProfile", "-File", $InstallPs1, "-InstallDir", $shouldNotExist, + "-DryRun", "-Channel", "bogus") + if ($invalidChannelResult.ExitCode -ne 0) { Pass "channel: unknown value rejected" } else { Fail "channel: unknown value should fail (exit=$LASTEXITCODE)" @@ -190,7 +372,7 @@ try { $freshDir = Join-Path $Work "fresh-beta" $freshConfigDir = Join-Path $Work "fresh-beta-config" $env:NETCLAW_CONFIG_DIR = $freshConfigDir - & pwsh -NoProfile -File $InstallPs1 -InstallDir $freshDir -Channel beta 2>&1 | Out-Null + & $PowerShellExecutable -NoProfile -File $InstallPs1 -InstallDir $freshDir -Channel beta -SkipShell 2>&1 | Out-Null $freshConfig = Join-Path $freshConfigDir "netclaw.json" if ((Test-Path $freshConfig)) { $c = Get-Content -Raw $freshConfig | ConvertFrom-Json @@ -209,7 +391,7 @@ try { New-Item -ItemType Directory -Path $existConfigDir -Force | Out-Null '{"configVersion":1,"Daemon":{"ExposureMode":"local"}}' | Set-Content -Path (Join-Path $existConfigDir "netclaw.json") -Encoding UTF8 $env:NETCLAW_CONFIG_DIR = $existConfigDir - & pwsh -NoProfile -File $InstallPs1 -InstallDir $existDir -Channel beta 2>&1 | Out-Null + & $PowerShellExecutable -NoProfile -File $InstallPs1 -InstallDir $existDir -Channel beta -SkipShell 2>&1 | Out-Null $c = Get-Content -Raw (Join-Path $existConfigDir "netclaw.json") | ConvertFrom-Json if ($c.Daemon.UpdateChannel -eq "beta" -and $c.Daemon.ExposureMode -eq "local") { Pass "config: -Channel beta patches existing config, preserves other Daemon keys" @@ -223,7 +405,7 @@ try { New-Item -ItemType Directory -Path $noflagConfigDir -Force | Out-Null '{"configVersion":1,"Daemon":{"UpdateChannel":"beta"}}' | Set-Content -Path (Join-Path $noflagConfigDir "netclaw.json") -Encoding UTF8 $env:NETCLAW_CONFIG_DIR = $noflagConfigDir - & pwsh -NoProfile -File $InstallPs1 -InstallDir $noflagDir 2>&1 | Out-Null + & $PowerShellExecutable -NoProfile -File $InstallPs1 -InstallDir $noflagDir -SkipShell 2>&1 | Out-Null $c = Get-Content -Raw (Join-Path $noflagConfigDir "netclaw.json") | ConvertFrom-Json if ($c.Daemon.UpdateChannel -eq "beta") { Pass "config: plain upgrade preserves existing beta channel" @@ -237,7 +419,7 @@ try { New-Item -ItemType Directory -Path $downConfigDir -Force | Out-Null '{"configVersion":1,"Daemon":{"UpdateChannel":"beta"}}' | Set-Content -Path (Join-Path $downConfigDir "netclaw.json") -Encoding UTF8 $env:NETCLAW_CONFIG_DIR = $downConfigDir - & pwsh -NoProfile -File $InstallPs1 -InstallDir $downDir -Channel stable 2>&1 | Out-Null + & $PowerShellExecutable -NoProfile -File $InstallPs1 -InstallDir $downDir -Channel stable -SkipShell 2>&1 | Out-Null $c = Get-Content -Raw (Join-Path $downConfigDir "netclaw.json") | ConvertFrom-Json if ($c.Daemon.UpdateChannel -eq "stable") { Pass "config: -Channel stable overwrites existing beta" @@ -247,8 +429,11 @@ try { } finally { if ($ServerProc -and -not $ServerProc.HasExited) { $ServerProc.Kill() } + Set-UserPathRegistryState $OriginalUserPath.Exists $OriginalUserPath.Value $OriginalUserPath.Kind + $env:PATH = $OriginalProcessPath $env:MANIFEST_URL = $null $env:NETCLAW_CONFIG_DIR = $null + $env:NETCLAW_SMOKE_INSTALL_DIR = $null Remove-Item -Path $Work -Recurse -Force -ErrorAction SilentlyContinue } @@ -259,7 +444,6 @@ if ($script:Fail -gt 0) { exit 1 } Write-Host "install smoke (ps1): PASSED" -# Exit explicitly on the result, not on $LASTEXITCODE — the channel checks above run -# `pwsh -Channel bogus` (which exits non-zero by design), and without this the script -# would fall off the end and inherit that non-zero code despite all assertions passing. +# Deliberate rejection checks run child processes that exit nonzero, so return +# the assertion result explicitly instead of inheriting a child's exit code. exit 0 diff --git a/scripts/smoke/install-smoke.sh b/scripts/smoke/install-smoke.sh index d11c39176..6cee3ee14 100755 --- a/scripts/smoke/install-smoke.sh +++ b/scripts/smoke/install-smoke.sh @@ -111,6 +111,26 @@ rm -f "$MANIFEST_DEST" bash "$MANIFEST_GEN" "$VERSION" "$WORK/checksums-$VERSION" "$BASE_URL" >/dev/null bash "$MANIFEST_GEN" "$BETA_VERSION" "$WORK/checksums-$BETA_VERSION" "$BASE_URL" >/dev/null cp "$MANIFEST_DEST" "$SERVE/manifest.json" +python3 - "$SERVE/manifest.json" "$SERVE/manifest-rid-first.json" <<'PY' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as source: + manifest = json.load(source) +for release in manifest["releases"]: + release["assets"] = [ + { + "rid": asset["rid"], + "component": asset["component"], + "url": asset["url"], + "sha256": asset["sha256"], + "sizeBytes": asset["sizeBytes"], + } + for asset in release["assets"] + ] +with open(sys.argv[2], "w", encoding="utf-8") as destination: + json.dump(manifest, destination) +PY # ── 4. Serve the manifest + archives from localhost ────────────────────────── python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$SERVE" >/dev/null 2>&1 & @@ -169,6 +189,66 @@ check_detect "macOS x86_64 + Rosetta -> osx-arm64" Darwin x86_64 1 'DRY RUN: wou check_detect "Intel Mac rejected" Darwin x86_64 0 'Apple Silicon' 1 check_detect "unsupported OS rejected" freebsd x86_64 0 'Unsupported OS' 1 +set +e +invalid_path_out=$(INSTALL_DIR="$WORK/invalid:path" \ + MANIFEST_URL="$BASE_URL/manifest.json" \ + bash "$INSTALL_SH" --dry-run 2>&1) +invalid_path_rc=$? +set -e +if [ "$invalid_path_rc" -ne 0 ] && echo "$invalid_path_out" | grep -q "cannot contain ':'"; then + pass "PATH: unrepresentable Unix install directory rejected" +else + fail "PATH: Unix install directory containing ':' was accepted" +fi + +PHYSICAL_INSTALL="$WORK/physical:install" +SYMLINK_INSTALL="$WORK/safe-install-link" +SYMLINK_HOME="$WORK/symlink-home" +mkdir -p "$PHYSICAL_INSTALL" "$SYMLINK_HOME" +ln -s "$PHYSICAL_INSTALL" "$SYMLINK_INSTALL" +set +e +symlink_path_out=$(HOME="$SYMLINK_HOME" SHELL="$(command -v bash)" \ + INSTALL_DIR="$SYMLINK_INSTALL" MANIFEST_URL="$BASE_URL/manifest.json" \ + bash "$INSTALL_SH" cli 2>&1) +symlink_path_rc=$? +set -e +if [ "$symlink_path_rc" -ne 0 ] \ + && echo "$symlink_path_out" | grep -q "cannot contain ':'" \ + && [ ! -e "$SYMLINK_HOME/.netclaw/env" ] \ + && [ ! -e "$SYMLINK_HOME/.bashrc" ] \ + && [ ! -e "$PHYSICAL_INSTALL/netclaw" ]; then + pass "PATH: physical install path is validated before install or shell mutation" +else + fail "PATH: unrepresentable symlink target was installed or persisted" + echo "$symlink_path_out" | indent +fi + +# Exercise the dependency-free parser with a valid manifest whose asset fields +# are in reverse order. A private mirror need not preserve JSON property order. +NO_JQ_BIN="$WORK/no-jq-bin" +mkdir -p "$NO_JQ_BIN" +for cmd in awk bash curl cut grep head mktemp rm sed sha256sum shasum tar tr uname; do + command_path=$(command -v "$cmd" 2>/dev/null || true) + if [ -n "$command_path" ]; then + ln -s "$command_path" "$NO_JQ_BIN/$cmd" + fi +done +set +e +no_jq_out=$(PATH="$NO_JQ_BIN" \ + MANIFEST_URL="$BASE_URL/manifest-rid-first.json" \ + INSTALL_DIR="$WORK/no-jq-install" \ + /bin/bash "$INSTALL_SH" --dry-run 2>&1) +no_jq_rc=$? +set -e +if [ "$no_jq_rc" -eq 0 ] \ + && echo "$no_jq_out" | grep -q "DRY RUN: would install netclaw .*/$VERSION/" \ + && echo "$no_jq_out" | grep -q "DRY RUN: would install netclawd .*/$VERSION/"; then + pass "manifest: jq-less parser selects stable assets with reordered fields" +else + fail "manifest: jq-less reordered-field parse failed (exit=$no_jq_rc)" + echo "$no_jq_out" | indent +fi + # Dry run must not create the install directory. if [ -d "$WORK/should-not-exist" ]; then fail "dry-run: created an install directory (should install nothing)" @@ -177,11 +257,16 @@ else fi # ── 6. Mechanical check: a real install on the host's native RID ───────────── +# Uses a temp HOME so shell integration writes to the temp dir, not the +# CI runner's real profile — and we can verify the RC was modified. echo "" echo "=== real install (host RID, stand-in archive) ===" -INSTALL_DIR="$WORK/installed" +INSTALL_HOME="$WORK/installed-home" +INSTALL_DIR="$INSTALL_HOME/.netclaw/bin" +mkdir -p "$INSTALL_HOME" set +e -install_out=$(MANIFEST_URL="$BASE_URL/manifest.json" INSTALL_DIR="$INSTALL_DIR" \ +install_out=$(HOME="$INSTALL_HOME" \ + MANIFEST_URL="$BASE_URL/manifest.json" INSTALL_DIR="$INSTALL_DIR" \ bash "$INSTALL_SH" 2>&1) install_rc=$? set -e @@ -201,6 +286,26 @@ for name in netclaw netclawd; do fi done +# Verify shell integration actually ran +INSTALL_ENV="$INSTALL_HOME/.netclaw/env" +if [ -f "$INSTALL_ENV" ]; then + pass "real install: env script created" +else + fail "real install: env script not found at $INSTALL_ENV" +fi + +# The RC file depends on $SHELL — check whichever one was created +RC_MODIFIED=false +for rc in "$INSTALL_HOME/.bashrc" "$INSTALL_HOME/.zshrc" "$INSTALL_HOME/.profile" "$INSTALL_HOME/.config/fish/conf.d/netclaw.fish"; do + if [ -f "$rc" ] && grep -qxF ". '$INSTALL_ENV'" "$rc" 2>/dev/null; then + pass "real install: $(basename "$rc") sources env script" + RC_MODIFIED=true + fi +done +if [ "$RC_MODIFIED" = false ]; then + fail "real install: no RC file sources env script (SHELL=$SHELL)" +fi + # ── 7. Release channel resolution (dry-run) ────────────────────────────────── echo "" echo "=== release channel resolution ===" @@ -256,7 +361,7 @@ set +e fresh_out=$(MANIFEST_URL="$BASE_URL/manifest.json" \ INSTALL_DIR="$FRESH_DIR" \ CONFIG_DIR="$FRESH_CONFIG_DIR" \ - bash "$INSTALL_SH" --channel beta 2>&1) + bash "$INSTALL_SH" --channel beta --skip-shell 2>&1) fresh_rc=$? set -e if [ "$fresh_rc" -eq 0 ] && [ -f "$FRESH_CONFIG_DIR/netclaw.json" ]; then @@ -284,7 +389,7 @@ set +e exist_out=$(MANIFEST_URL="$BASE_URL/manifest.json" \ INSTALL_DIR="$EXIST_DIR" \ CONFIG_DIR="$EXIST_CONFIG_DIR" \ - bash "$INSTALL_SH" --channel beta 2>&1) + bash "$INSTALL_SH" --channel beta --skip-shell 2>&1) exist_rc=$? set -e if [ "$exist_rc" -eq 0 ] && command -v jq >/dev/null 2>&1; then @@ -297,6 +402,7 @@ if [ "$exist_rc" -eq 0 ] && command -v jq >/dev/null 2>&1; then fi else fail "config: --channel beta on existing config (exit=$exist_rc)" + echo "$exist_out" | indent fi # 8c. Plain upgrade (no --channel) leaves existing beta config alone @@ -308,7 +414,7 @@ set +e noflag_out=$(MANIFEST_URL="$BASE_URL/manifest.json" \ INSTALL_DIR="$NOFLAG_DIR" \ CONFIG_DIR="$NOFLAG_CONFIG_DIR" \ - bash "$INSTALL_SH" 2>&1) + bash "$INSTALL_SH" --skip-shell 2>&1) noflag_rc=$? set -e if [ "$noflag_rc" -eq 0 ] && command -v jq >/dev/null 2>&1; then @@ -320,6 +426,7 @@ if [ "$noflag_rc" -eq 0 ] && command -v jq >/dev/null 2>&1; then fi else fail "config: plain upgrade (exit=$noflag_rc)" + echo "$noflag_out" | indent fi # 8d. --channel stable on existing beta overwrites to stable @@ -331,7 +438,7 @@ set +e down_out=$(MANIFEST_URL="$BASE_URL/manifest.json" \ INSTALL_DIR="$DOWNGRADE_DIR" \ CONFIG_DIR="$DOWNGRADE_CONFIG_DIR" \ - bash "$INSTALL_SH" --channel stable 2>&1) + bash "$INSTALL_SH" --channel stable --skip-shell 2>&1) down_rc=$? set -e if [ "$down_rc" -eq 0 ] && command -v jq >/dev/null 2>&1; then @@ -343,6 +450,153 @@ if [ "$down_rc" -eq 0 ] && command -v jq >/dev/null 2>&1; then fi else fail "config: --channel stable on existing beta (exit=$down_rc)" + echo "$down_out" | indent +fi + +# ── 9. Shell integration (PATH automation) ─────────────────────────────────── +echo "" +echo "=== shell integration ===" + +assert_path_once() { + local desc="$1" observed_path="$2" install_dir="$3" + local count + count=$(printf '%s' "$observed_path" | tr ':' '\n' | grep -cxF "$install_dir" || true) + if [ "$count" -eq 1 ]; then + pass "$desc: install directory appears exactly once on PATH" + else + fail "$desc: install directory appears $count times on PATH" + fi +} + +run_unix_installer() { + local shell_path="$1" home="$2" install_dir="$3" + shift 3 + SHELL="$shell_path" HOME="$home" \ + MANIFEST_URL="$BASE_URL/manifest.json" INSTALL_DIR="$install_dir" \ + CONFIG_DIR="$home/.netclaw/config" \ + bash "$INSTALL_SH" "$@" +} + +# Bash: run the generated startup path through Bash itself, then repeat the +# install to prove both profile mutation and PATH evaluation are idempotent. +BASH_HOME="$WORK/shell-bash" +BASH_INSTALL="$BASH_HOME/netclaw install's/bin" +mkdir -p "$BASH_HOME" +if [ "$(uname -s)" = "Darwin" ]; then + BASH_RC="$BASH_HOME/.bash_profile" + printf '# existing bash profile' > "$BASH_RC" + printf '# profile must remain untouched\n' > "$BASH_HOME/.profile" +else + BASH_RC="$BASH_HOME/.bashrc" + printf '# existing bash rc' > "$BASH_RC" +fi + +if run_unix_installer "$(command -v bash)" "$BASH_HOME" "$BASH_INSTALL" >/dev/null \ + && run_unix_installer "$(command -v bash)" "$BASH_HOME" "$BASH_INSTALL" >/dev/null; then + BASH_INSTALL_PHYSICAL=$(cd "$BASH_INSTALL" && pwd -P) + bash_path=$(PATH="/usr/bin:/bin" HOME="$BASH_HOME" \ + bash --noprofile --rcfile "$BASH_RC" -i -c 'printf "%s" "$PATH"' 2>/dev/null) + assert_path_once "bash" "$bash_path" "$BASH_INSTALL_PHYSICAL" + bash_empty_path=$(PATH="" HOME="$BASH_HOME" \ + /bin/bash --noprofile --rcfile "$BASH_RC" -i -c 'printf "%s" "$PATH"' 2>/dev/null) + if [ "$bash_empty_path" = "$BASH_INSTALL_PHYSICAL" ]; then + pass "bash: empty PATH does not introduce a current-directory entry" + else + fail "bash: empty PATH produced '$bash_empty_path'" + fi + source_count=$(grep -cF "$BASH_HOME/.netclaw/env" "$BASH_RC" || true) + if [ "$source_count" -eq 1 ]; then + pass "bash: profile source line is idempotent" + else + fail "bash: profile contains $source_count netclaw source lines" + fi + if [ "$(uname -s)" = "Darwin" ] && ! grep -qF netclaw "$BASH_HOME/.profile"; then + pass "bash-macos: existing .bash_profile wins over .profile" + fi +else + fail "bash: installer failed" +fi + +# Zsh: resolve a non-exported ZDOTDIR from .zshenv, then execute the selected +# startup file under zsh so a Bash-compatible false positive cannot pass. +if command -v zsh >/dev/null 2>&1; then + ZSH_HOME="$WORK/shell-zsh" + ZDOT_DIR="$ZSH_HOME/custom-zdotdir" + ZSH_INSTALL="$ZSH_HOME/netclaw install's/bin" + mkdir -p "$ZDOT_DIR" + printf "ZDOTDIR='%s'\n" "$ZDOT_DIR" > "$ZSH_HOME/.zshenv" + printf '# existing zsh config\n' > "$ZDOT_DIR/.zshrc" + if (unset ZDOTDIR; run_unix_installer "$(command -v zsh)" "$ZSH_HOME" "$ZSH_INSTALL" >/dev/null) \ + && (unset ZDOTDIR; run_unix_installer "$(command -v zsh)" "$ZSH_HOME" "$ZSH_INSTALL" >/dev/null); then + ZSH_INSTALL_PHYSICAL=$(cd "$ZSH_INSTALL" && pwd -P) + zsh_path=$(PATH="/usr/bin:/bin" ZDOTDIR="$ZDOT_DIR" \ + zsh -f -c 'source "$ZDOTDIR/.zshrc"; print -rn -- "$PATH"') + assert_path_once "zsh" "$zsh_path" "$ZSH_INSTALL_PHYSICAL" + if [ ! -e "$ZSH_HOME/.zshrc" ]; then + pass "zsh: non-exported ZDOTDIR is authoritative" + else + fail "zsh: installer touched ~/.zshrc despite ZDOTDIR" + fi + else + fail "zsh: installer failed" + fi +else + echo "SKIP: zsh executable not available" +fi + +# Fish owns a native conf.d file. Execute that file with fish, not Bash. +if command -v fish >/dev/null 2>&1; then + FISH_HOME="$WORK/shell-fish" + FISH_INSTALL="$FISH_HOME/netclaw install's/bin" + FISH_RC="$FISH_HOME/.config/fish/conf.d/netclaw.fish" + if XDG_CONFIG_HOME="$FISH_HOME/.config" \ + run_unix_installer "$(command -v fish)" "$FISH_HOME" "$FISH_INSTALL" >/dev/null \ + && XDG_CONFIG_HOME="$FISH_HOME/.config" \ + run_unix_installer "$(command -v fish)" "$FISH_HOME" "$FISH_INSTALL" >/dev/null; then + FISH_INSTALL_PHYSICAL=$(cd "$FISH_INSTALL" && pwd -P) + fish_path=$(PATH="/usr/bin:/bin" fish --no-config -c \ + "source '$FISH_RC'; string join : -- \$PATH") + assert_path_once "fish" "$fish_path" "$FISH_INSTALL_PHYSICAL" + else + fail "fish: installer failed" + fi +else + echo "SKIP: fish executable not available" +fi + +# Opt-out under a supported shell must print a self-contained command instead +# of referring to an env file that was not made. +MANUAL_HOME="$WORK/shell-skip" +MANUAL_INSTALL="$MANUAL_HOME/netclaw install's/bin" +mkdir -p "$MANUAL_HOME" +manual_out=$(run_unix_installer "$(command -v bash)" "$MANUAL_HOME" "$MANUAL_INSTALL" --skip-shell) +manual_command=$(printf '%s\n' "$manual_out" | sed -n 's/^ \{0,4\}\(export PATH=.*\)$/\1/p' | head -1) +if [ -n "$manual_command" ] && [ ! -e "$MANUAL_HOME/.netclaw/env" ]; then + MANUAL_INSTALL_PHYSICAL=$(cd "$MANUAL_INSTALL" && pwd -P) + manual_path=$(PATH="/usr/bin:/bin" bash -c "$manual_command; printf '%s' \"\$PATH\"") + assert_path_once "skip" "$manual_path" "$MANUAL_INSTALL_PHYSICAL" + manual_empty_path=$(PATH="" /bin/bash -c "$manual_command; printf '%s' \"\$PATH\"") + if [ "$manual_empty_path" = "$MANUAL_INSTALL_PHYSICAL" ]; then + pass "skip: manual command preserves an empty PATH without adding current directory" + else + fail "skip: manual command produced '$manual_empty_path' from an empty PATH" + fi +else + fail "skip: missing usable manual PATH command or created shell files" +fi + +# Unsupported shells get shell-neutral guidance; emitting Bash syntax for an +# arbitrary shell would make the suggested command actively misleading. +UNKNOWN_HOME="$WORK/shell-unknown" +UNKNOWN_INSTALL="$UNKNOWN_HOME/netclaw install's/bin" +unknown_out=$(run_unix_installer /bin/unknownshell "$UNKNOWN_HOME" "$UNKNOWN_INSTALL") +UNKNOWN_INSTALL_PHYSICAL=$(cd "$UNKNOWN_INSTALL" && pwd -P) +if echo "$unknown_out" | grep -qF "$UNKNOWN_INSTALL_PHYSICAL" \ + && ! echo "$unknown_out" | grep -q 'export PATH=' \ + && [ ! -e "$UNKNOWN_HOME/.netclaw/env" ]; then + pass "unknown: guidance is shell-neutral and no shell files are created" +else + fail "unknown: guidance is shell-specific or shell files were created" fi # ── Summary ──────────────────────────────────────────────────────────────────