diff --git a/installer/go/pkg/nodejs/system.go b/installer/go/pkg/nodejs/system.go index 57da95d3..95e7913b 100644 --- a/installer/go/pkg/nodejs/system.go +++ b/installer/go/pkg/nodejs/system.go @@ -19,30 +19,6 @@ import ( // not to the machine, so the service account cannot be expected to reach it. var userPathPrefixes = []string{"/home/", "/Users/", "/root", `c:\users\`} -// systemNodeCandidates returns the fixed locations a system-wide Node.js is -// accepted from, for the current operating system. -// -// Returns: -// - []string: The absolute candidate paths, empty on an unsupported system -func systemNodeCandidates() []string { - switch runtime.GOOS { - case "linux": - return []string{"/usr/bin/node", "/usr/local/bin/node"} - case "darwin": - return []string{"/usr/local/bin/node", "/opt/homebrew/bin/node"} - case "windows": - var candidates []string - for _, envVar := range []string{"ProgramFiles", "ProgramFiles(x86)"} { - if programFiles := os.Getenv(envVar); programFiles != "" { - candidates = append(candidates, filepath.Join(programFiles, "nodejs", "node.exe")) - } - } - return candidates - default: - return nil - } -} - // npmExecutableName returns the name of the npm executable on this platform. // // Returns: diff --git a/installer/go/pkg/nodejs/system_unix.go b/installer/go/pkg/nodejs/system_unix.go new file mode 100644 index 00000000..0b1ac7a5 --- /dev/null +++ b/installer/go/pkg/nodejs/system_unix.go @@ -0,0 +1,21 @@ +//go:build linux || darwin + +package nodejs + +import "runtime" + +// systemNodeCandidates returns the fixed locations a system-wide Node.js is +// accepted from, for the current operating system. +// +// Returns: +// - []string: The absolute candidate paths, empty on an unsupported system +// +func systemNodeCandidates() []string { + switch runtime.GOOS { + case "linux": + return []string{"/usr/bin/node", "/usr/local/bin/node"} + case "darwin": + return []string{"/usr/local/bin/node", "/opt/homebrew/bin/node"} + } + return nil +} diff --git a/installer/go/pkg/nodejs/system_windows.go b/installer/go/pkg/nodejs/system_windows.go new file mode 100644 index 00000000..1854f781 --- /dev/null +++ b/installer/go/pkg/nodejs/system_windows.go @@ -0,0 +1,132 @@ +//go:build windows + +package nodejs + +import ( + "os" + "path/filepath" + "strings" + + "github.com/flowfuse/device-agent-installer/pkg/logger" + + "golang.org/x/sys/windows/registry" +) + +// nodeRegistryValueNames lists the value names under SOFTWARE\Node.js that may +// hold the Node.js installation directory or executable path. +var nodeRegistryValueNames = []string{"InstallPath", "InstallDir", "Path"} + +// normaliseRegistryNodePath turns a registry value describing a Node.js +// installation into a candidate node.exe path. Surrounding whitespace and +// quotes are trimmed; a directory is completed with node.exe, a value that +// already names an executable is kept as-is. +// +// Parameters: +// - raw: The registry value, a Node.js directory or node.exe path +// +// Returns: +// - string: The normalised path, "" when the value holds nothing usable +func normaliseRegistryNodePath(raw string) string { + path := strings.TrimSpace(raw) + path = strings.Trim(path, `"`) + path = strings.TrimSpace(path) + if path == "" { + return "" + } + if !strings.EqualFold(filepath.Ext(path), ".exe") { + path = filepath.Join(path, "node.exe") + } + return path +} + +// nodePathsFromRegistry returns node.exe candidates read from the Windows +// registry, in the order they were found. MSI installs register their +// installation in SOFTWARE\Node.js, and some installs only leave an App Paths +// entry pointing straight at node.exe behind. Both 64-bit and 32-bit registry +// views are consulted, so a 32-bit Node.js stays visible from a 64-bit +// installer. The current-user hive is read as well, but a per-user install it +// reveals is rejected later by the user-path check in DetectSystemNode. +// +// Returns: +// - []string: The unique candidate paths, empty when the registry names none +func nodePathsFromRegistry() []string { + var candidates []string + seen := map[string]bool{} + + add := func(raw string) { + path := normaliseRegistryNodePath(raw) + if path == "" { + return + } + key := strings.ToLower(path) + if seen[key] { + return + } + seen[key] = true + candidates = append(candidates, path) + } + + roots := []registry.Key{registry.LOCAL_MACHINE, registry.CURRENT_USER} + keyPaths := []string{`SOFTWARE\Node.js`, `SOFTWARE\WOW6432Node\Node.js`} + accesses := []uint32{ + registry.QUERY_VALUE | registry.WOW64_64KEY, + registry.QUERY_VALUE | registry.WOW64_32KEY, + } + + for _, root := range roots { + for _, keyPath := range keyPaths { + for _, access := range accesses { + key, err := registry.OpenKey(root, keyPath, access) + if err != nil { + continue + } + for _, valueName := range nodeRegistryValueNames { + if value, _, err := key.GetStringValue(valueName); err == nil { + add(value) + } + } + key.Close() + } + } + } + + // App Paths fallback for installs that registered one without leaving + // SOFTWARE\Node.js behind. Its default value points directly at node.exe. + appPathsKey := `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\node.exe` + for _, access := range accesses { + key, err := registry.OpenKey(registry.LOCAL_MACHINE, appPathsKey, access) + if err != nil { + continue + } + if value, _, err := key.GetStringValue(""); err == nil { + add(value) + } + key.Close() + } + + if len(candidates) > 0 { + logger.Debug("Node.js install paths found in the Windows registry: %s", strings.Join(candidates, ", ")) + } + + return candidates +} + +// systemNodeCandidates returns the locations a system-wide Node.js is accepted +// from: the install paths registered in the Windows registry by MSI installs +// first, then the conventional %ProgramFiles% locations covering non-MSI and +// portable installs. Every candidate still has to pass the checks in +// DetectSystemNode, so a registered path that is invalid, user-owned or too +// old is skipped there. +// +// Returns: +// - []string: The absolute candidate node.exe paths, empty when none is known +// +func systemNodeCandidates() []string { + candidates := nodePathsFromRegistry() + for _, envVar := range []string{"ProgramFiles", "ProgramFiles(x86)"} { + if programFiles := os.Getenv(envVar); programFiles != "" { + candidates = append(candidates, filepath.Join(programFiles, "nodejs", "node.exe")) + } + } + return candidates +}