Skip to content

Commit f7c9bc5

Browse files
committed
fix(install): validate PATH integration through real shells
1 parent a917a9e commit f7c9bc5

6 files changed

Lines changed: 333 additions & 447 deletions

File tree

.github/workflows/pr_validation.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@ jobs:
184184
with:
185185
fetch-depth: 1
186186

187+
- name: "Install shell integration test dependencies"
188+
if: runner.os == 'Linux'
189+
run: sudo apt-get update && sudo apt-get install --yes fish zsh
190+
187191
- name: "install.sh smoke test"
188192
if: runner.os != 'Windows'
189193
shell: bash

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,14 @@ curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --channel beta
100100

101101
# Pin a specific version (e.g. a prerelease)
102102
NETCLAW_VERSION=0.17.1 curl -sSL https://releases.netclaw.dev/install.sh | bash
103+
104+
# Install without modifying your shell profile
105+
curl -sSL https://releases.netclaw.dev/install.sh | bash -s -- --skip-shell
103106
```
104107

108+
By default, the Unix installer updates the detected Bash, zsh, or fish startup
109+
configuration so new shells include Netclaw on `PATH`.
110+
105111
**macOS** (Apple Silicon — M1 or later — installs CLI + daemon to `~/.netclaw/bin`):
106112

107113
```bash
@@ -119,8 +125,9 @@ available on macOS ([#1015](https://github.com/netclaw-dev/netclaw/issues/1015))
119125
iwr -useb https://releases.netclaw.dev/install.ps1 | iex
120126
```
121127

122-
The `-Component cli|daemon`, `-Channel beta`, and `-Version` options work the same
123-
way as their Linux counterparts (download the script and run it with the flag).
128+
The installer adds Netclaw to your User PATH. The `-Component cli|daemon`,
129+
`-Channel beta`, `-Version`, and `-SkipShell` options work the same way as their
130+
Linux counterparts (download the script and run it with the flag).
124131

125132
**Docker** (multi-arch: amd64/arm64):
126133

scripts/install.ps1

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -264,50 +264,77 @@ try {
264264
Write-Host ""
265265

266266
if (-not $SkipShell) {
267-
$installDirNormalized = $InstallDir.TrimEnd('\')
267+
$installDirNormalized = [System.IO.Path]::TrimEndingDirectorySeparator($InstallDir)
268268

269269
$userPath = [Environment]::GetEnvironmentVariable("PATH", "User")
270-
$pathEntries = if ([string]::IsNullOrEmpty($userPath)) { @() } else {
271-
$userPath -split ';' | ForEach-Object { $_.TrimEnd('\') }
270+
$userPathEntries = if ([string]::IsNullOrEmpty($userPath)) { @() } else {
271+
$userPath -split ';' |
272+
Where-Object { -not [string]::IsNullOrEmpty($_) } |
273+
ForEach-Object { [System.IO.Path]::TrimEndingDirectorySeparator($_) }
272274
}
273275

274-
if ($pathEntries -contains $installDirNormalized) {
275-
Write-Host "Installation complete! netclaw is already on your PATH."
276-
} else {
277-
# Check length limit (Windows API caps at 32767)
278-
$newPath = "$installDirNormalized;$userPath"
279-
if ($newPath.Length -gt 32700) {
280-
Write-Warning "PATH is near its 32,767 character limit ($($newPath.Length) chars)."
281-
Write-Host "Please manually add $InstallDir to your PATH."
276+
$userPathChanged = $false
277+
if ($userPathEntries -notcontains $installDirNormalized) {
278+
$newUserPath = if ([string]::IsNullOrEmpty($userPath)) {
279+
$installDirNormalized
280+
} else {
281+
"$installDirNormalized;$userPath"
282+
}
283+
284+
if ($newUserPath.Length -gt 32700) {
285+
Write-Warning "User PATH is near its 32,767 character limit ($($newUserPath.Length) chars)."
286+
Write-Host "Please manually add $InstallDir to your User PATH."
287+
} else {
288+
[Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")
289+
$userPathChanged = $true
290+
}
291+
}
292+
293+
$processPath = $env:PATH
294+
$processPathEntries = if ([string]::IsNullOrEmpty($processPath)) { @() } else {
295+
$processPath -split ';' |
296+
Where-Object { -not [string]::IsNullOrEmpty($_) } |
297+
ForEach-Object { [System.IO.Path]::TrimEndingDirectorySeparator($_) }
298+
}
299+
if ($processPathEntries -notcontains $installDirNormalized) {
300+
$env:PATH = if ([string]::IsNullOrEmpty($processPath)) {
301+
$installDirNormalized
282302
} else {
283-
[Environment]::SetEnvironmentVariable("PATH", $newPath, "User")
284-
# Also update current session
285-
$env:PATH = $newPath
303+
"$installDirNormalized;$processPath"
304+
}
305+
}
286306

287-
# Broadcast WM_SETTINGCHANGE to Explorer so new terminal windows pick up the change
288-
Add-Type -Namespace WinAPI -Name Func -MemberDefinition @'
307+
if ($userPathChanged) {
308+
if (-not ("NetclawInstaller.NativeMethods" -as [type])) {
309+
Add-Type -Namespace NetclawInstaller -Name NativeMethods -MemberDefinition @'
289310
[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]
290311
public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint msg,
291312
UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
292313
'@
293-
[WinAPI.Func]::SendMessageTimeout(
294-
[IntPtr]0xFFFF, 0x001A, [UIntPtr]::Zero, "Environment", 2, 1000, [ref][UIntPtr]::Zero
295-
) | Out-Null
296-
297-
Write-Host "Installation complete! netclaw is on your PATH."
298-
Write-Host ""
299-
Write-Host "Start a new terminal, or run in the current session:"
300-
Write-Host ""
301-
Write-Host " `$env:PATH += ';$InstallDir'"
302314
}
315+
316+
$broadcastOutput = [UIntPtr]::Zero
317+
$broadcastResult = [NetclawInstaller.NativeMethods]::SendMessageTimeout(
318+
[IntPtr]0xFFFF, 0x001A, [UIntPtr]::Zero, "Environment", 2, 1000, [ref]$broadcastOutput)
319+
if ($broadcastResult -eq [IntPtr]::Zero) {
320+
Write-Warning "User PATH was updated, but Windows did not acknowledge the environment-change notification. New terminals may require sign-out or restart."
321+
}
322+
}
323+
324+
if ($userPathEntries -contains $installDirNormalized) {
325+
Write-Host "Installation complete! netclaw is already on your User PATH."
326+
} elseif ($userPathChanged) {
327+
Write-Host "Installation complete! netclaw was added to your User PATH."
328+
} else {
329+
Write-Host "Installation complete! netclaw is on PATH for this terminal only."
303330
}
304331
} else {
305332
Write-Host "Installation complete! (PATH modification skipped)"
306333
Write-Host ""
307334
Write-Host "Add Netclaw to your PATH by running:"
308335
Write-Host ""
309336
Write-Host " `$userPath = [Environment]::GetEnvironmentVariable('PATH', 'User')"
310-
Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"`$InstallDir;`$userPath`", 'User')"
337+
Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$InstallDir;`$userPath`", 'User')"
311338
Write-Host ""
312339
Write-Host "Then restart your terminal."
313340
}

scripts/install.sh

Lines changed: 72 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ download_component() {
201201
block=$(echo "$MANIFEST" | tr '\n' ' ' | grep -oP "\"component\"\\s*:\\s*\"${component}\"[^}]*\"rid\"\\s*:\\s*\"${RID}\"[^}]*}" | head -1)
202202
if [ -z "$block" ]; then
203203
# Try reversed order
204-
block=$(echo "$MANIFEST" | tr '\n' ' ' | grep -oP "\"rid\"\\s*:\\s*\"${RID}\"[^}]*\"component\"\\s*:\\s*\"${RID}\"[^}]*}" | head -1)
204+
block=$(echo "$MANIFEST" | tr '\n' ' ' | grep -oP "\"rid\"\\s*:\\s*\"${RID}\"[^}]*\"component\"\\s*:\\s*\"${component}\"[^}]*}" | head -1)
205205
fi
206206
url=$(echo "$block" | grep -oP '"url"\s*:\s*"\K[^"]+')
207207
sha256=$(echo "$block" | grep -oP '"sha256"\s*:\s*"\K[^"]+')
@@ -309,17 +309,20 @@ if [ "$CHANNEL_EXPLICIT" = true ]; then
309309
fi
310310

311311
# ── Shell integration ─────────────────────────────────────────────────────
312-
# Write an intermediary env script (~/.netclaw/env) and source it from the
313-
# user's shell RC file. The env script self-guards at runtime so duplicate
314-
# PATH entries cannot occur even if the RC is sourced multiple times.
315-
316-
# Derive the parent directory of INSTALL_DIR — this is where the env script
317-
# lives. For the default (~/.netclaw/bin) that's ~/.netclaw.
318-
# We use dirname because the install dir may not yet exist when this block runs
319-
# (e.g., if the user passes --skip-shell and no component needs the dir).
320-
INSTALL_ROOT="$(dirname "$INSTALL_DIR")"
321-
ENV_SCRIPT="$INSTALL_ROOT/env"
322-
SOURCE_LINE=". \"$ENV_SCRIPT\""
312+
# Bash and zsh source a small POSIX env file. Fish gets native syntax in its
313+
# dedicated conf.d file; fish cannot source POSIX `case ... esac` syntax.
314+
ENV_SCRIPT="$HOME/.netclaw/env"
315+
316+
shell_quote() {
317+
printf "'"
318+
printf '%s' "$1" | sed "s/'/'\\\\''/g"
319+
printf "'"
320+
}
321+
322+
INSTALL_DIR_QUOTED="$(shell_quote "$INSTALL_DIR")"
323+
ENV_SCRIPT_QUOTED="$(shell_quote "$ENV_SCRIPT")"
324+
SOURCE_LINE=". $ENV_SCRIPT_QUOTED"
325+
MANUAL_PATH_LINE="export PATH=$INSTALL_DIR_QUOTED:\$PATH"
323326

324327
detect_shell() {
325328
# $SHELL is inherited from the parent login shell — it reflects the user's
@@ -339,73 +342,56 @@ get_rc_file() {
339342
echo "${ZDOTDIR:-$HOME}/.zshrc"
340343
;;
341344
bash)
342-
# macOS login shells read ~/.profile; Linux interactive shells
343-
# read ~/.bashrc. We prefer .bashrc on Linux and .profile on macOS.
344345
if [ "$os" = "darwin" ]; then
345-
echo "$HOME/.profile"
346+
# A login shell reads only the first existing file in this list.
347+
if [ -f "$HOME/.bash_profile" ]; then
348+
echo "$HOME/.bash_profile"
349+
elif [ -f "$HOME/.bash_login" ]; then
350+
echo "$HOME/.bash_login"
351+
else
352+
echo "$HOME/.profile"
353+
fi
346354
else
347355
echo "$HOME/.bashrc"
348356
fi
349357
;;
350-
fish)
351-
local fish_conf_dir="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d"
352-
echo "$fish_conf_dir/netclaw.fish"
353-
;;
354358
*)
355359
echo ""
356360
;;
357361
esac
358362
}
359363

360-
write_env_script() {
361-
# Create the self-guarding env script. Uses a colon-affixed case guard
362-
# (rustup/fzf pattern) to prevent duplicate PATH entries at runtime.
364+
write_posix_env_script() {
363365
mkdir -p "$(dirname "$ENV_SCRIPT")"
364366
cat > "$ENV_SCRIPT" <<ENVEOF
365367
#!/bin/sh
366368
# netclaw shell setup
369+
netclaw_bin=$INSTALL_DIR_QUOTED
367370
case ":\${PATH}:" in
368-
*:"$INSTALL_DIR":*)
371+
*:"\${netclaw_bin}":*)
369372
;;
370373
*)
371-
export PATH="$INSTALL_DIR:\${PATH}"
374+
export PATH="\${netclaw_bin}:\${PATH}"
372375
;;
373376
esac
377+
unset netclaw_bin
374378
ENVEOF
375-
chmod +x "$ENV_SCRIPT"
376379
}
377380

378-
modify_rc_file() {
379-
local shell_name="$1"
380-
local rc_file
381-
382-
rc_file="$(get_rc_file "$shell_name")"
383-
if [ -z "$rc_file" ]; then
384-
echo " Shell '$shell_name' is not supported for automatic PATH setup."
385-
echo " Add this to your shell profile:"
386-
echo ""
387-
echo " $SOURCE_LINE"
388-
return 0
389-
fi
390-
391-
# Ensure the RC file's parent directory exists (fish conf.d may not)
381+
modify_posix_rc_file() {
382+
local rc_file="$1"
392383
mkdir -p "$(dirname "$rc_file")"
393-
394-
# Touch the file so it exists — some users have no RC file yet
395384
touch "$rc_file"
396385

397-
# Guard: check if the source line already exists
398386
if grep -qxF "$SOURCE_LINE" "$rc_file" 2>/dev/null; then
399387
echo " Shell profile '$rc_file' already sources netclaw."
400388
return 0
401389
fi
402390

403-
# Ensure a trailing newline before appending
404391
if [ -s "$rc_file" ] && [ "$(tail -c1 "$rc_file" | wc -l)" -eq 0 ]; then
405392
echo "" >> "$rc_file"
406393
fi
407394

408-
# Append the marker comment and source line
409395
{
410396
echo "# netclaw shell setup"
411397
echo "$SOURCE_LINE"
@@ -414,35 +400,58 @@ modify_rc_file() {
414400
echo " Modified '$rc_file' to add netclaw to PATH."
415401
}
416402

403+
write_fish_config() {
404+
local fish_config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/fish/conf.d"
405+
local fish_config="$fish_config_dir/netclaw.fish"
406+
mkdir -p "$fish_config_dir"
407+
cat > "$fish_config" <<FISHEOF
408+
# netclaw shell setup
409+
set -l netclaw_bin $INSTALL_DIR_QUOTED
410+
if not contains -- \$netclaw_bin \$PATH
411+
set -gx PATH \$netclaw_bin \$PATH
412+
end
413+
FISHEOF
414+
echo " Wrote '$fish_config' to add netclaw to PATH."
415+
}
416+
417417
if [ "$SKIP_SHELL" = false ]; then
418418
SHELL_NAME="$(detect_shell)"
419419
echo ""
420420
echo "Setting up shell integration..."
421421

422-
# Write env script first — it must exist before we tell the RC to source it
423-
write_env_script
424-
425-
# Modify the RC file to source the env script
426-
modify_rc_file "$SHELL_NAME"
427-
428-
echo ""
429-
echo "Installation complete! netclaw is on your PATH."
430-
echo ""
431-
echo "Start a new shell, or run:"
432-
echo ""
433-
echo " $SOURCE_LINE"
422+
case "$SHELL_NAME" in
423+
bash|zsh)
424+
RC_FILE="$(get_rc_file "$SHELL_NAME")"
425+
write_posix_env_script
426+
modify_posix_rc_file "$RC_FILE"
427+
echo ""
428+
echo "Installation complete! netclaw will be on PATH in new shells."
429+
echo "To update this shell, run:"
430+
echo ""
431+
echo " $SOURCE_LINE"
432+
;;
433+
fish)
434+
write_fish_config
435+
echo ""
436+
echo "Installation complete! netclaw will be on PATH in new fish shells."
437+
echo "To update this shell, run:"
438+
echo ""
439+
echo " set -gx PATH $INSTALL_DIR_QUOTED \$PATH"
440+
;;
441+
*)
442+
echo " Shell '$SHELL_NAME' is not supported for automatic PATH setup."
443+
echo " No shell profile was changed. Add this to your shell profile:"
444+
echo ""
445+
echo " $MANUAL_PATH_LINE"
446+
;;
447+
esac
434448
else
435-
# --skip-shell was passed
436449
echo ""
437450
echo "Installation complete! (shell integration skipped)"
438451
echo ""
439452
echo "Add netclaw to your PATH by adding this to your shell profile:"
440453
echo ""
441-
echo " $SOURCE_LINE"
442-
echo ""
443-
echo "Then restart your shell or run:"
444-
echo ""
445-
echo " source ~/.bashrc # or ~/.zshrc"
454+
echo " $MANUAL_PATH_LINE"
446455
fi
447456

448457
echo ""
@@ -451,4 +460,4 @@ echo " netclaw init # First-run setup wizard"
451460
echo " netclaw doctor # Verify configuration"
452461
if [ "$(uname -s)" = "Linux" ]; then
453462
echo " netclaw daemon install # Enable auto-start on boot (systemd)"
454-
fi
463+
fi

0 commit comments

Comments
 (0)