| 1 | package hook |
| 2 | |
| 3 | import ( |
| 4 | "path/filepath" |
| 5 | "strings" |
| 6 | |
| 7 | "reasonix/internal/pluginpkg" |
| 8 | ) |
| 9 | |
| 10 | func completePluginHookExecutionConfig(h pluginpkg.Hook, root, goos string, mode ExecutionMode) HookConfig { |
| 11 | candidate := expandPluginRoot(h.Command, root) |
| 12 | if candidate != "" && !filepath.IsAbs(candidate) { |
| 13 | candidate = filepath.Join(root, filepath.FromSlash(candidate)) |
| 14 | } |
| 15 | autoPOSIXScript := goos == "windows" && mode == ExecutionLegacy && h.Args == nil && isPOSIXShellScriptFile(candidate) |
| 16 | if autoPOSIXScript { |
| 17 | mode = ExecutionShell |
| 18 | h.Shell = "bash" |
| 19 | } |
| 20 | explicitBash := goos == "windows" && mode == ExecutionShell && strings.EqualFold(strings.TrimSpace(h.Shell), "bash") |
| 21 | expansionRoot := root |
| 22 | if explicitBash { |
| 23 | expansionRoot = strings.ReplaceAll(root, `\`, "/") |
| 24 | } |
| 25 | command := expandPluginRoot(h.Command, expansionRoot) |
| 26 | resolveFromPluginRoot := (mode != ExecutionShell || autoPOSIXScript) && |
| 27 | !(mode == ExecutionExec && h.PayloadFormat == "claude") |
| 28 | if command != "" && resolveFromPluginRoot && !filepath.IsAbs(command) { |
| 29 | command = filepath.Join(root, filepath.FromSlash(command)) |
| 30 | } |
| 31 | if mode == ExecutionLegacy { |
| 32 | command = NormalizeCommand(command) |
| 33 | } else if goos == "windows" && windowsShellMayUsePOSIXPath(h.Shell) { |
| 34 | if scriptPath, ok := windowsPOSIXScriptPath(command, root); ok { |
| 35 | h.Shell = "bash" |
| 36 | command = bashSingleQuote(filepath.ToSlash(scriptPath)) |
| 37 | } |
| 38 | } |
| 39 | var argv []string |
| 40 | if h.ArgsSet { |
| 41 | argv = make([]string, 0, len(h.Args)) |
| 42 | } |
| 43 | for _, arg := range h.Args { |
| 44 | argv = append(argv, expandPluginRoot(arg, root)) |
| 45 | } |
| 46 | return HookConfig{ |
| 47 | Command: command, |
| 48 | Argv: argv, |
| 49 | ExecutionMode: mode, |
| 50 | Shell: h.Shell, |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | func bashSingleQuote(value string) string { |
| 55 | if value == "" { |
| 56 | return "''" |
| 57 | } |
| 58 | return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" |
| 59 | } |
| 60 | |
| 61 | func windowsShellMayUsePOSIXPath(shell string) bool { |
| 62 | switch strings.ToLower(strings.TrimSpace(shell)) { |
| 63 | case "", "auto", "bash": |
| 64 | return true |
| 65 | default: |
| 66 | return false |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func windowsPOSIXScriptPath(command, cwd string) (string, bool) { |
| 71 | raw := strings.TrimSpace(command) |
| 72 | rawPath := filepath.FromSlash(raw) |
| 73 | if filepath.IsAbs(rawPath) && isPOSIXShellScriptFile(rawPath) { |
| 74 | return filepath.Clean(rawPath), true |
| 75 | } |
| 76 | if len(raw) >= 2 && ((raw[0] == '"' && raw[len(raw)-1] == '"') || (raw[0] == '\'' && raw[len(raw)-1] == '\'')) { |
| 77 | path := filepath.FromSlash(raw[1 : len(raw)-1]) |
| 78 | if filepath.IsAbs(path) && isPOSIXShellScriptFile(path) { |
| 79 | return filepath.Clean(path), true |
| 80 | } |
| 81 | } |
| 82 | fields, _, _, ok := parseSimpleHookCommandFields(command) |
| 83 | if !ok || len(fields) != 1 { |
| 84 | return "", false |
| 85 | } |
| 86 | path := filepath.FromSlash(fields[0]) |
| 87 | if !filepath.IsAbs(path) && strings.TrimSpace(cwd) != "" { |
| 88 | path = filepath.Join(cwd, path) |
| 89 | } |
| 90 | if !isPOSIXShellScriptFile(path) { |
| 91 | return "", false |
| 92 | } |
| 93 | return filepath.Clean(path), true |
| 94 | } |
| 95 | |
| 96 | func normalizeWindowsHookSpawnInputForPlatform(in SpawnInput, goos string) SpawnInput { |
| 97 | if goos != "windows" || in.Mode != ExecutionLegacy || in.Args != nil { |
| 98 | return in |
| 99 | } |
| 100 | if scriptPath, ok := windowsPOSIXScriptPath(in.Command, in.Cwd); ok { |
| 101 | in.Mode = ExecutionShell |
| 102 | in.Shell = "bash" |
| 103 | in.Command = bashSingleQuote(filepath.ToSlash(scriptPath)) |
| 104 | } |
| 105 | return in |
| 106 | } |
| 107 | |
| 108 | func requiresWindowsBashForHook(config HookConfig) bool { |
| 109 | switch config.ExecutionMode { |
| 110 | case ExecutionShell: |
| 111 | if strings.EqualFold(strings.TrimSpace(config.Shell), "bash") { |
| 112 | return true |
| 113 | } |
| 114 | return windowsShellMayUsePOSIXPath(config.Shell) && func() bool { |
| 115 | _, ok := windowsPOSIXScriptPath(config.Command, config.Cwd) |
| 116 | return ok |
| 117 | }() |
| 118 | case ExecutionExec: |
| 119 | return isBarePOSIXShellWord(config.Command) && hasCommandStringFlag(config.Argv) |
| 120 | case ExecutionLegacy: |
| 121 | if config.Argv != nil { |
| 122 | return isBarePOSIXShellWord(config.Command) && hasCommandStringFlag(config.Argv) |
| 123 | } |
| 124 | if _, ok := windowsPOSIXScriptPath(config.Command, config.Cwd); ok { |
| 125 | return true |
| 126 | } |
| 127 | fields, _, _, ok := parseSimpleHookCommandFields(config.Command) |
| 128 | return ok && len(fields) >= 3 && isBarePOSIXShellWord(fields[0]) && hasCommandStringFlag(fields[1:]) |
| 129 | default: |
| 130 | return false |
| 131 | } |
| 132 | } |
| 133 |