package toolingplan import ( "context" "fmt" "strings" ) type nodeDetector struct{} func (nodeDetector) detect(_ context.Context, repoRoot string, managedTools map[string]struct{}) detectionResult { result := detectionResult{} nodeVersion, nodeSource, nodeManaged, nodeSkip := detectNodeVersion(repoRoot, managedTools) if nodeManaged { result.Skips = append(result.Skips, SkipNote{Target: "node", Reason: "already managed by repo mise declarations"}) } else if nodeVersion != "" { result.Steps = append(result.Steps, InstallStep{Tool: "node", Version: nodeVersion, Source: nodeSource, Reason: "exact runtime version"}) } else { result.Skips = append(result.Skips, SkipNote{Target: "node", Reason: nodeSkip}) } packageManagerVersion, packageManagerTool, packageManagerSource, packageManagerSkip := detectNodePackageManager(repoRoot) if packageManagerTool == "" { if packageManagerSkip != "" { result.Skips = append(result.Skips, SkipNote{Target: "node package manager", Reason: packageManagerSkip}) } return result } if alreadyManaged(packageManagerTool, managedTools) { result.Skips = append(result.Skips, SkipNote{Target: "node package manager", Reason: packageManagerTool + " is already managed by repo mise declarations"}) return result } if nodeVersion == "" && !alreadyManaged("node", managedTools) { result.Skips = append(result.Skips, SkipNote{Target: "node package manager", Reason: "packageManager is pinned but node is not pinned"}) return result } result.Steps = append(result.Steps, InstallStep{Tool: packageManagerTool, Version: packageManagerVersion, Source: packageManagerSource, Reason: "exact packageManager version"}) return result } func detectNodeVersion(repoRoot string, managedTools map[string]struct{}) (version string, source string, managed bool, skip string) { if alreadyManaged("node", managedTools) { return "", "", true, "" } for _, candidate := range []string{".node-version", ".nvmrc"} { value, ok, err := readRepoFile(repoRoot, candidate) if err != nil { return "", "", false, fmt.Sprintf("could not read %s: %v", candidate, err) } if !ok { continue } version, ok := normalizeExactVersion(strings.TrimSpace(value)) if ok { return version, candidate, false, "" } return "", "", false, candidate + " does not pin an exact version" } packageJSON, ok, err := readRepoFile(repoRoot, "package.json") if err != nil { return "", "", false, fmt.Sprintf("could not read package.json: %v", err) } if !ok { return "", "", false, "no pinned node version file" } meta, err := parsePackageJSON(packageJSON) if err != nil { return "", "", false, fmt.Sprintf("could not parse package.json: %v", err) } if version, ok := normalizeExactVersion(meta.Volta.Node); ok { return version, "package.json#volta.node", false, "" } if strings.TrimSpace(meta.Volta.Node) != "" { return "", "", false, "package.json#volta.node is not an exact version" } return "", "", false, "no pinned node version file" } func detectNodePackageManager(repoRoot string) (version string, tool string, source string, skip string) { packageJSON, ok, err := readRepoFile(repoRoot, "package.json") if err != nil { return "", "", "", fmt.Sprintf("could not read package.json: %v", err) } if !ok { return "", "", "", "" } meta, err := parsePackageJSON(packageJSON) if err != nil { return "", "", "", fmt.Sprintf("could not parse package.json: %v", err) } value := strings.TrimSpace(meta.PackageManager) if value == "" { return "", "", "", "" } parts := strings.SplitN(value, "@", 2) if len(parts) != 2 { return "", "", "", "packageManager is not in tool@version form" } tool = strings.TrimSpace(parts[0]) if tool != "pnpm" && tool != "yarn" && tool != "npm" && tool != "bun" { return "", "", "", "packageManager is not a supported exact installer target" } version, ok = normalizeExactVersion(parts[1]) if !ok { return "", "", "", "packageManager version is not exact" } return version, tool, "package.json#packageManager", "" }