Skip to content

Commit 64d40bf

Browse files
committed
Fix gt doctor false warning for modular shell configs
hasShellIntegration() only checked the top-level RC file for the Gas Town marker. Users with modular shell setups (e.g. .zshrc sourcing profile-specific files) got a false "Shell integration not installed" warning. Now recursively follows source/. directives (up to depth 5) and also detects manual sourcing of shell-hook.sh. Handles variable expansion, glob fallback for unresolvable variables, conditional source patterns, and circular reference protection.
1 parent bdbe8c4 commit 64d40bf

2 files changed

Lines changed: 499 additions & 2 deletions

File tree

internal/doctor/global_state_check.go

Lines changed: 216 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package doctor
66
import (
77
"os"
88
"path/filepath"
9+
"sort"
910
"strings"
1011

1112
"github.com/steveyegge/gastown/internal/shell"
@@ -103,9 +104,222 @@ func (c *GlobalStateCheck) Run(ctx *CheckContext) *CheckResult {
103104
}
104105

105106
func hasShellIntegration(rcPath string) bool {
106-
data, err := os.ReadFile(rcPath)
107+
// Look for official marker (from gt shell install) or manual sourcing of the hook script.
108+
markers := []string{"Gas Town Integration", "shell-hook.sh"}
109+
return checkSourceChain(rcPath, markers, make(map[string]bool), 0)
110+
}
111+
112+
// checkSourceChain reads filePath, checks for any marker string, and
113+
// recursively follows source/. directives found in the file. This handles
114+
// users with modular shell configs (e.g. .zshrc sources profile-specific
115+
// files that source the Gas Town hook script).
116+
func checkSourceChain(filePath string, markers []string, visited map[string]bool, depth int) bool {
117+
if depth > 5 {
118+
return false
119+
}
120+
121+
absPath, err := filepath.Abs(filePath)
107122
if err != nil {
108123
return false
109124
}
110-
return strings.Contains(string(data), "Gas Town Integration")
125+
if visited[absPath] {
126+
return false
127+
}
128+
visited[absPath] = true
129+
130+
data, err := os.ReadFile(absPath)
131+
if err != nil {
132+
return false
133+
}
134+
content := string(data)
135+
136+
for _, marker := range markers {
137+
if strings.Contains(content, marker) {
138+
return true
139+
}
140+
}
141+
142+
homeDir, _ := os.UserHomeDir()
143+
vars := extractShellVars(content, homeDir)
144+
145+
for _, line := range strings.Split(content, "\n") {
146+
for _, sourced := range resolveSourcePaths(line, homeDir, vars) {
147+
if checkSourceChain(sourced, markers, visited, depth+1) {
148+
return true
149+
}
150+
}
151+
}
152+
153+
return false
154+
}
155+
156+
// extractShellVars extracts simple variable assignments (VAR="val" or
157+
// export VAR="val") from shell content for resolving paths in source
158+
// directives. Command substitutions and complex expressions are ignored.
159+
func extractShellVars(content, homeDir string) map[string]string {
160+
vars := map[string]string{"HOME": homeDir}
161+
162+
for _, line := range strings.Split(content, "\n") {
163+
line = strings.TrimSpace(line)
164+
if strings.HasPrefix(line, "#") {
165+
continue
166+
}
167+
line = strings.TrimPrefix(line, "export ")
168+
169+
eqIdx := strings.Index(line, "=")
170+
if eqIdx == -1 {
171+
continue
172+
}
173+
174+
name := strings.TrimSpace(line[:eqIdx])
175+
if !isShellVarName(name) {
176+
continue
177+
}
178+
179+
value := strings.TrimSpace(line[eqIdx+1:])
180+
value = unquoteShell(value)
181+
182+
// Skip command substitutions and complex expressions
183+
if strings.Contains(value, "$(") || strings.Contains(value, "`") {
184+
continue
185+
}
186+
187+
value = expandShellVars(value, vars, homeDir)
188+
vars[name] = value
189+
}
190+
191+
return vars
192+
}
193+
194+
func isShellVarName(s string) bool {
195+
if s == "" {
196+
return false
197+
}
198+
for i, c := range s {
199+
if c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') {
200+
continue
201+
}
202+
if i > 0 && c >= '0' && c <= '9' {
203+
continue
204+
}
205+
return false
206+
}
207+
return true
208+
}
209+
210+
func unquoteShell(s string) string {
211+
if len(s) >= 2 {
212+
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
213+
return s[1 : len(s)-1]
214+
}
215+
}
216+
return s
217+
}
218+
219+
// expandShellVars expands ~, ${VAR}, and $VAR references. Longer variable
220+
// names are replaced first to avoid partial prefix matches.
221+
func expandShellVars(s string, vars map[string]string, homeDir string) string {
222+
if strings.HasPrefix(s, "~/") {
223+
s = homeDir + s[1:]
224+
}
225+
226+
for name, value := range vars {
227+
s = strings.ReplaceAll(s, "${"+name+"}", value)
228+
}
229+
230+
names := make([]string, 0, len(vars))
231+
for name := range vars {
232+
names = append(names, name)
233+
}
234+
sort.Slice(names, func(i, j int) bool {
235+
return len(names[i]) > len(names[j])
236+
})
237+
for _, name := range names {
238+
s = strings.ReplaceAll(s, "$"+name, vars[name])
239+
}
240+
241+
return s
242+
}
243+
244+
// resolveSourcePaths extracts file paths from source/. directives,
245+
// expanding variables and falling back to glob patterns for unresolved
246+
// variables.
247+
func resolveSourcePaths(line, homeDir string, vars map[string]string) []string {
248+
line = strings.TrimSpace(line)
249+
if strings.HasPrefix(line, "#") {
250+
return nil
251+
}
252+
253+
// Strip conditional prefixes: [[ ... ]] && source ..., [[ ! ... ]] || source ...
254+
for _, sep := range []string{"&& source ", "|| source ", "&& . ", "|| . "} {
255+
if idx := strings.Index(line, sep); idx != -1 {
256+
line = strings.TrimSpace(line[idx+3:])
257+
break
258+
}
259+
}
260+
261+
var raw string
262+
switch {
263+
case strings.HasPrefix(line, "source "):
264+
raw = strings.TrimSpace(line[7:])
265+
case strings.HasPrefix(line, ". "):
266+
raw = strings.TrimSpace(line[2:])
267+
default:
268+
return nil
269+
}
270+
271+
raw = unquoteShell(raw)
272+
273+
// Strip trailing inline comment
274+
if idx := strings.Index(raw, " #"); idx != -1 {
275+
raw = strings.TrimSpace(raw[:idx])
276+
}
277+
278+
resolved := expandShellVars(raw, vars, homeDir)
279+
280+
if !strings.Contains(resolved, "$") {
281+
return []string{resolved}
282+
}
283+
284+
// Unresolved variables remain — try glob by replacing $VAR with *
285+
globbed := replaceUnresolvedVars(resolved)
286+
if strings.ContainsAny(globbed, "?[") {
287+
return nil
288+
}
289+
matches, err := filepath.Glob(globbed)
290+
if err != nil || len(matches) == 0 {
291+
return nil
292+
}
293+
return matches
294+
}
295+
296+
// replaceUnresolvedVars replaces remaining $VAR and ${VAR} patterns with *
297+
// so the path can be used as a glob pattern.
298+
func replaceUnresolvedVars(s string) string {
299+
var b strings.Builder
300+
i := 0
301+
for i < len(s) {
302+
if s[i] == '$' {
303+
if i+1 < len(s) && s[i+1] == '{' {
304+
end := strings.Index(s[i:], "}")
305+
if end != -1 {
306+
b.WriteByte('*')
307+
i += end + 1
308+
continue
309+
}
310+
}
311+
j := i + 1
312+
for j < len(s) && (s[j] == '_' || (s[j] >= 'A' && s[j] <= 'Z') || (s[j] >= 'a' && s[j] <= 'z') || (j > i+1 && s[j] >= '0' && s[j] <= '9')) {
313+
j++
314+
}
315+
if j > i+1 {
316+
b.WriteByte('*')
317+
i = j
318+
continue
319+
}
320+
}
321+
b.WriteByte(s[i])
322+
i++
323+
}
324+
return b.String()
111325
}

0 commit comments

Comments
 (0)