Skip to content

Commit fdfc75c

Browse files
Repair shell PATH during install and setup
1 parent ffae0e4 commit fdfc75c

6 files changed

Lines changed: 343 additions & 30 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ macOS/Linux/WSL:
1616
curl -fsSL https://codag.ai/install.sh | sh
1717
```
1818

19+
The installer writes `codag` to `${HOME}/.local/bin` by default, adds that
20+
directory to your shell startup file when needed, and then tells you to run
21+
`codag setup`.
22+
1923
Native Windows binaries are still published as release ZIP files.
2024

2125
From source:

cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
)
1414

1515
var (
16-
Version = "0.1.6"
16+
Version = "0.1.7"
1717
Commit = "none"
1818
BuildDate = "unknown"
1919
)

install.sh

Lines changed: 91 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,94 @@ install_bash_completion() {
144144
fi
145145
}
146146

147+
escape_double_quoted() {
148+
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\$/\\$/g; s/`/\\`/g'
149+
}
150+
151+
shell_startup_target() {
152+
if [ -z "${HOME:-}" ]; then
153+
return 1
154+
fi
155+
156+
shell_name="$(basename "${SHELL:-sh}")"
157+
case "$shell_name" in
158+
zsh)
159+
printf '%s|sh\n' "$HOME/.zshrc"
160+
;;
161+
bash)
162+
if [ -f "$HOME/.bash_profile" ]; then
163+
printf '%s|sh\n' "$HOME/.bash_profile"
164+
else
165+
printf '%s|sh\n' "$HOME/.bashrc"
166+
fi
167+
;;
168+
fish)
169+
printf '%s|fish\n' "$HOME/.config/fish/config.fish"
170+
;;
171+
*)
172+
printf '%s|sh\n' "$HOME/.profile"
173+
;;
174+
esac
175+
}
176+
177+
ensure_install_dir_on_path() {
178+
install_dir="$1"
179+
180+
target="$(shell_startup_target || true)"
181+
if [ -z "$target" ]; then
182+
warn "Could not determine a shell startup file. Add ${install_dir} to PATH manually."
183+
return 1
184+
fi
185+
profile="${target%%|*}"
186+
syntax="${target##*|}"
187+
escaped_dir="$(escape_double_quoted "$install_dir")"
188+
tmp_profile="${profile}.codag.tmp.$$"
189+
190+
if ! mkdir -p "$(dirname "$profile")" 2>/dev/null; then
191+
warn "Could not create $(dirname "$profile"). Add ${install_dir} to PATH manually."
192+
return 1
193+
fi
194+
195+
if [ -f "$profile" ]; then
196+
awk '
197+
$0 == "# >>> codag PATH >>>" { skip = 1; next }
198+
$0 == "# <<< codag PATH <<<" { skip = 0; next }
199+
skip != 1 { print }
200+
' "$profile" > "$tmp_profile" || {
201+
rm -f "$tmp_profile"
202+
warn "Could not update ${profile}. Add ${install_dir} to PATH manually."
203+
return 1
204+
}
205+
else
206+
: > "$tmp_profile"
207+
fi
208+
209+
if [ -s "$tmp_profile" ]; then
210+
printf '\n' >> "$tmp_profile"
211+
fi
212+
printf '# >>> codag PATH >>>\n' >> "$tmp_profile"
213+
if [ "$syntax" = "fish" ]; then
214+
printf 'fish_add_path -g "%s"\n' "$escaped_dir" >> "$tmp_profile"
215+
else
216+
printf 'export PATH="%s:$PATH"\n' "$escaped_dir" >> "$tmp_profile"
217+
fi
218+
printf '# <<< codag PATH <<<\n' >> "$tmp_profile"
219+
220+
if mv "$tmp_profile" "$profile"; then
221+
success "Added ${install_dir} to PATH in ${profile}"
222+
if [ "$syntax" = "fish" ]; then
223+
printf ' Restart your shell after setup, or run: %bfish_add_path -g "%s"%b\n' "$BOLD" "$install_dir" "$NC"
224+
else
225+
printf ' Restart your shell after setup, or run: %bexport PATH="%s:$PATH"%b\n' "$BOLD" "$install_dir" "$NC"
226+
fi
227+
return 0
228+
fi
229+
230+
rm -f "$tmp_profile"
231+
warn "Could not update ${profile}. Add ${install_dir} to PATH manually."
232+
return 1
233+
}
234+
147235
main() {
148236
if ! command -v curl >/dev/null 2>&1; then
149237
error "curl is required but not installed. Please install curl and try again."
@@ -217,37 +305,11 @@ main() {
217305
printf ' %bWARNING: PATH conflict detected%b\n\n' "$YELLOW" "$NC"
218306
printf ' Installed to: %s\n' "$installed_binary"
219307
printf ' But %s resolves to: %s\n\n' "$BINARY" "$path_binary"
220-
printf ' Either remove the old binary or adjust your PATH to prioritize %s\n\n' "$INSTALL_DIR"
308+
ensure_install_dir_on_path "$INSTALL_DIR" || true
309+
printf '\n'
221310
elif [ -z "$path_binary" ]; then
222-
shell_name="$(basename "${SHELL:-sh}")"
223-
224-
case "$shell_name" in
225-
zsh) shell_config="~/.zshrc" ;;
226-
bash)
227-
if [ -f "$HOME/.bash_profile" ]; then
228-
shell_config="~/.bash_profile"
229-
else
230-
shell_config="~/.bashrc"
231-
fi
232-
;;
233-
fish) shell_config="~/.config/fish/config.fish" ;;
234-
*) shell_config="" ;;
235-
esac
236-
237311
printf '\n'
238-
printf ' %bAlmost there!%b Add Codag to your PATH:\n\n' "$YELLOW" "$NC"
239-
240-
if [ "$shell_name" = "fish" ]; then
241-
printf ' %bfish_add_path %s%b\n\n' "$BOLD" "$INSTALL_DIR" "$NC"
242-
printf ' Add to %s to make it permanent.\n' "$shell_config"
243-
elif [ -n "$shell_config" ]; then
244-
printf ' Run this, then restart your terminal:\n\n'
245-
printf ' %becho '\''export PATH="%s:$PATH"'\'' >> %s%b\n' "$BOLD" "$INSTALL_DIR" "$shell_config" "$NC"
246-
else
247-
printf ' Add this to your shell config:\n\n'
248-
printf ' %bexport PATH="%s:$PATH"%b\n' "$BOLD" "$INSTALL_DIR" "$NC"
249-
fi
250-
312+
ensure_install_dir_on_path "$INSTALL_DIR" || true
251313
printf '\n'
252314
fi
253315

internal/setup/path.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package setup
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
"strings"
11+
)
12+
13+
const (
14+
pathBlockBegin = "# >>> codag PATH >>>"
15+
pathBlockEnd = "# <<< codag PATH <<<"
16+
)
17+
18+
type shellPathTarget struct {
19+
Path string
20+
Syntax string
21+
}
22+
23+
func ensureExecutableOnPath(opts Options) {
24+
exe, err := os.Executable()
25+
if err != nil {
26+
return
27+
}
28+
dir := filepath.Dir(exe)
29+
if dir == "." || dir == string(filepath.Separator) {
30+
return
31+
}
32+
if pathResolvesTo(exe) {
33+
return
34+
}
35+
target, ok := shellPathTargetForEnv()
36+
if !ok {
37+
fmt.Printf(" %s PATH: add %s to your shell PATH.\n", yellow("!"), dir)
38+
return
39+
}
40+
if opts.PrintOnly {
41+
fmt.Printf(" [dry-run] would add %s to PATH in %s\n", dir, displayHome(target.Path))
42+
return
43+
}
44+
if err := writeShellPathBlock(target, dir); err != nil {
45+
fmt.Printf(" %s PATH: could not update %s: %v\n", yellow("!"), displayHome(target.Path), err)
46+
return
47+
}
48+
fmt.Printf(" %s added %s to PATH in %s\n", green("✓"), dir, displayHome(target.Path))
49+
fmt.Println(dim(" Restart your shell after setup if `codag` is not available immediately."))
50+
}
51+
52+
func pathResolvesTo(exe string) bool {
53+
path, err := exec.LookPath("codag")
54+
if err != nil {
55+
return false
56+
}
57+
return samePath(path, exe)
58+
}
59+
60+
func samePath(a, b string) bool {
61+
aa, errA := filepath.EvalSymlinks(a)
62+
if errA != nil {
63+
aa = a
64+
}
65+
bb, errB := filepath.EvalSymlinks(b)
66+
if errB != nil {
67+
bb = b
68+
}
69+
return filepath.Clean(aa) == filepath.Clean(bb)
70+
}
71+
72+
func shellPathTargetForEnv() (shellPathTarget, bool) {
73+
home, err := os.UserHomeDir()
74+
if err != nil || home == "" {
75+
return shellPathTarget{}, false
76+
}
77+
shellName := filepath.Base(os.Getenv("SHELL"))
78+
switch shellName {
79+
case "zsh":
80+
return shellPathTarget{Path: filepath.Join(home, ".zshrc"), Syntax: "sh"}, true
81+
case "bash":
82+
profile := filepath.Join(home, ".bash_profile")
83+
if _, err := os.Stat(profile); err == nil {
84+
return shellPathTarget{Path: profile, Syntax: "sh"}, true
85+
}
86+
return shellPathTarget{Path: filepath.Join(home, ".bashrc"), Syntax: "sh"}, true
87+
case "fish":
88+
return shellPathTarget{Path: filepath.Join(home, ".config", "fish", "config.fish"), Syntax: "fish"}, true
89+
default:
90+
if runtime.GOOS == "windows" {
91+
return shellPathTarget{}, false
92+
}
93+
return shellPathTarget{Path: filepath.Join(home, ".profile"), Syntax: "sh"}, true
94+
}
95+
}
96+
97+
func writeShellPathBlock(target shellPathTarget, dir string) error {
98+
if err := os.MkdirAll(filepath.Dir(target.Path), 0o700); err != nil {
99+
return err
100+
}
101+
var existing string
102+
if data, err := os.ReadFile(target.Path); err == nil { // #nosec G304 -- setup only edits the user's shell startup file.
103+
existing = string(data)
104+
} else if !os.IsNotExist(err) {
105+
return err
106+
}
107+
108+
base, err := removeShellPathBlock(existing)
109+
if err != nil {
110+
return err
111+
}
112+
base = strings.TrimRight(base, "\n")
113+
if base != "" {
114+
base += "\n\n"
115+
}
116+
base += renderShellPathBlock(target.Syntax, dir)
117+
return os.WriteFile(target.Path, []byte(base), 0o600)
118+
}
119+
120+
func removeShellPathBlock(existing string) (string, error) {
121+
var out []string
122+
skip := false
123+
scanner := bufio.NewScanner(strings.NewReader(existing))
124+
for scanner.Scan() {
125+
line := scanner.Text()
126+
switch line {
127+
case pathBlockBegin:
128+
skip = true
129+
continue
130+
case pathBlockEnd:
131+
skip = false
132+
continue
133+
}
134+
if !skip {
135+
out = append(out, line)
136+
}
137+
}
138+
if err := scanner.Err(); err != nil {
139+
return "", err
140+
}
141+
return strings.Join(out, "\n"), nil
142+
}
143+
144+
func renderShellPathBlock(syntax, dir string) string {
145+
escaped := escapeDoubleQuoted(dir)
146+
if syntax == "fish" {
147+
return fmt.Sprintf("%s\nfish_add_path -g \"%s\"\n%s\n", pathBlockBegin, escaped, pathBlockEnd)
148+
}
149+
return fmt.Sprintf("%s\nexport PATH=\"%s:$PATH\"\n%s\n", pathBlockBegin, escaped, pathBlockEnd)
150+
}
151+
152+
func escapeDoubleQuoted(s string) string {
153+
replacer := strings.NewReplacer(
154+
`\`, `\\`,
155+
`"`, `\"`,
156+
`$`, `\$`,
157+
"`", "\\`",
158+
)
159+
return replacer.Replace(s)
160+
}
161+
162+
func displayHome(path string) string {
163+
home, err := os.UserHomeDir()
164+
if err != nil || home == "" {
165+
return path
166+
}
167+
if path == home {
168+
return "~"
169+
}
170+
prefix := home + string(filepath.Separator)
171+
if strings.HasPrefix(path, prefix) {
172+
return "~/" + strings.TrimPrefix(path, prefix)
173+
}
174+
return path
175+
}

0 commit comments

Comments
 (0)