package pipeline import ( "bufio" "fmt" "os" "os/exec" "path/filepath" "regexp" "runtime" "strings" "gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project" ) func resolveScriptCompilerEnvironment(p *project.Project) ([]string, error) { env := os.Environ() userDir, err := resolveNWNUserDirectory(p.ScriptCompilerNWNUserDirectoryEnvKeys()) if err != nil { return nil, err } env = upsertEnv(env, "NWN_HOME", userDir) env = upsertEnv(env, "NWN_USER_DIRECTORY", userDir) root, err := resolveNWNRoot(p.ScriptCompilerNWNRootEnvKeys()) if err != nil { return nil, err } if root != "" { env = upsertEnv(env, "NWN_ROOT", root) } return env, nil } func resolveNWNUserDirectory(envKeys []string) (string, error) { for _, key := range envKeys { if value := strings.TrimSpace(os.Getenv(key)); value != "" { path := filepath.Clean(expandUserPath(value)) if err := os.MkdirAll(path, 0o755); err != nil { return "", fmt.Errorf("prepare NWN user directory %s from %s: %w", path, key, err) } return path, nil } } path, err := defaultNWNUserDirectory() if err != nil { return "", err } if err := os.MkdirAll(path, 0o755); err != nil { return "", fmt.Errorf("prepare default NWN user directory %s: %w", path, err) } return path, nil } func defaultNWNUserDirectory() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve user home directory: %w", err) } switch runtime.GOOS { case "windows", "darwin": return filepath.Join(home, "Documents", "Neverwinter Nights"), nil default: base := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")) if base == "" { base = filepath.Join(home, ".local", "share") } return filepath.Join(base, "Neverwinter Nights"), nil } } func resolveNWNRoot(envKeys []string) (string, error) { for _, key := range envKeys { if value := strings.TrimSpace(os.Getenv(key)); value != "" { path := filepath.Clean(expandUserPath(value)) if !looksLikeNWNInstall(path) { return "", fmt.Errorf("%s does not point to a Neverwinter Nights install root: %s", key, path) } return path, nil } } for _, root := range candidateNWNInstallRoots() { if looksLikeNWNInstall(root) { return root, nil } } return "", nil } func candidateNWNInstallRoots() []string { candidates := make([]string, 0, 8) for _, library := range steamLibraryRoots() { candidates = append(candidates, filepath.Join(library, "steamapps", "common", "Neverwinter Nights")) } switch runtime.GOOS { case "windows": for _, base := range []string{ strings.TrimSpace(os.Getenv("PROGRAMFILES(X86)")), strings.TrimSpace(os.Getenv("PROGRAMFILES")), } { if base == "" { continue } candidates = append(candidates, filepath.Join(base, "Steam", "steamapps", "common", "Neverwinter Nights")) } case "darwin": if home, err := os.UserHomeDir(); err == nil { candidates = append(candidates, filepath.Join(home, "Library", "Application Support", "Steam", "steamapps", "common", "Neverwinter Nights")) } default: if home, err := os.UserHomeDir(); err == nil { candidates = append(candidates, filepath.Join(home, ".local", "share", "Steam", "steamapps", "common", "Neverwinter Nights"), filepath.Join(home, ".steam", "steam", "steamapps", "common", "Neverwinter Nights"), filepath.Join(home, ".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam", "steamapps", "common", "Neverwinter Nights"), ) } } return dedupePaths(candidates) } func steamLibraryRoots() []string { steamRoots := defaultSteamRoots() roots := make([]string, 0, len(steamRoots)+4) roots = append(roots, steamRoots...) for _, steamRoot := range steamRoots { roots = append(roots, parseSteamLibraryFolders(filepath.Join(steamRoot, "steamapps", "libraryfolders.vdf"))...) } return dedupePaths(roots) } func defaultSteamRoots() []string { roots := make([]string, 0, 6) if runtime.GOOS == "windows" { roots = append(roots, detectWindowsSteamRoot()) roots = append(roots, filepath.Join(strings.TrimSpace(os.Getenv("PROGRAMFILES(X86)")), "Steam"), filepath.Join(strings.TrimSpace(os.Getenv("PROGRAMFILES")), "Steam"), ) return dedupePaths(filterNonEmpty(roots)) } home, err := os.UserHomeDir() if err != nil { return nil } switch runtime.GOOS { case "darwin": roots = append(roots, filepath.Join(home, "Library", "Application Support", "Steam")) default: roots = append(roots, filepath.Join(home, ".local", "share", "Steam"), filepath.Join(home, ".steam", "steam"), filepath.Join(home, ".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam"), ) } return dedupePaths(filterNonEmpty(roots)) } func detectWindowsSteamRoot() string { for _, args := range [][]string{ {"query", `HKCU\Software\Valve\Steam`, "/v", "SteamPath"}, {"query", `HKLM\SOFTWARE\WOW6432Node\Valve\Steam`, "/v", "InstallPath"}, {"query", `HKLM\SOFTWARE\Valve\Steam`, "/v", "InstallPath"}, } { out, err := exec.Command("reg", args...).CombinedOutput() if err != nil { continue } if path := parseWindowsRegistryPath(string(out)); path != "" { return filepath.Clean(path) } } return "" } func parseWindowsRegistryPath(raw string) string { index := strings.Index(raw, "REG_SZ") if index == -1 { return "" } return strings.TrimSpace(raw[index+len("REG_SZ"):]) } func parseSteamLibraryFolders(path string) []string { raw, err := os.ReadFile(path) if err != nil { return nil } pathPattern := regexp.MustCompile(`"path"\s*"([^"]+)"`) legacyPattern := regexp.MustCompile(`^\s*"\d+"\s*"([^"]+)"\s*$`) roots := make([]string, 0, 8) scanner := bufio.NewScanner(strings.NewReader(string(raw))) for scanner.Scan() { line := scanner.Text() if match := pathPattern.FindStringSubmatch(line); len(match) == 2 { roots = append(roots, normalizeSteamPath(match[1])) continue } if match := legacyPattern.FindStringSubmatch(line); len(match) == 2 { roots = append(roots, normalizeSteamPath(match[1])) } } return dedupePaths(filterNonEmpty(roots)) } func normalizeSteamPath(value string) string { value = strings.TrimSpace(value) value = strings.ReplaceAll(value, `\\`, `\`) return filepath.Clean(expandUserPath(value)) } func looksLikeNWNInstall(path string) bool { if strings.TrimSpace(path) == "" { return false } info, err := os.Stat(filepath.Join(path, "data")) return err == nil && info.IsDir() } func expandUserPath(path string) string { if path == "" || path[0] != '~' { return path } home, err := os.UserHomeDir() if err != nil { return path } if path == "~" { return home } if path[1] == '/' || path[1] == '\\' { return filepath.Join(home, path[2:]) } return path } func upsertEnv(env []string, key, value string) []string { prefix := key + "=" for i, entry := range env { if strings.HasPrefix(entry, prefix) { env[i] = prefix + value return env } } return append(env, prefix+value) } func dedupePaths(paths []string) []string { seen := make(map[string]struct{}, len(paths)) out := make([]string, 0, len(paths)) for _, path := range paths { if strings.TrimSpace(path) == "" { continue } clean := filepath.Clean(path) if _, ok := seen[clean]; ok { continue } seen[clean] = struct{}{} out = append(out, clean) } return out } func filterNonEmpty(values []string) []string { out := make([]string, 0, len(values)) for _, value := range values { if strings.TrimSpace(value) != "" { out = append(out, value) } } return out }