Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,4 @@ Use `internal/ui` for all user-facing output:
- **Version strings**: Strip `v` prefix (e.g., "v22.0.0" → "22.0.0")
- **Registry is global**: Providers auto-register on import via `init()`
- **Verbose mode**: Set `DTVEM_VERBOSE=1` for debug output
- **Windows PATH in the registry**: write it with `writePathValue` (`internal/path/registry_windows.go`), never `registry.Key.SetStringValue` or .NET's `[Environment]::SetEnvironmentVariable`. Those write `REG_SZ`, and Windows only expands `%VAR%` entries such as `%SystemRoot%\system32` in a `REG_EXPAND_SZ` value. `dtvem doctor` (`path-registry-type`) repairs a converted User PATH.
62 changes: 48 additions & 14 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,46 @@ function Test-Checksum {
return $true
}

function Send-EnvironmentChange {
# Tell running applications (Explorer, new terminals) that the
# environment changed, as [Environment]::SetEnvironmentVariable would.
$signature = '[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'
$native = Add-Type -MemberDefinition $signature -Name 'NativeMethods' -Namespace 'DtvemInstaller' -PassThru
$result = [UIntPtr]::Zero
[void]$native::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$result)
}

function Add-ToUserPath {
param([string]$Directory)

# Edit the User PATH in the registry directly.
# [Environment]::GetEnvironmentVariable returns it with %VAR% entries
# expanded and SetEnvironmentVariable writes REG_SZ, which together would
# freeze entries like %USERPROFILE%\.dotnet\tools and change the value's
# type. Windows stores PATH as REG_EXPAND_SZ.
$key = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
if (-not $key) {
throw "Could not open HKCU\Environment"
}
try {
$userPath = $key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
$entries = [Environment]::ExpandEnvironmentVariables($userPath) -split ';'
if ($entries -contains $Directory) {
return $false
}

# Add to BEGINNING of PATH for priority over system versions
$newPath = if ($userPath) { "$Directory;$userPath" } else { $Directory }
$key.SetValue('Path', $newPath, [Microsoft.Win32.RegistryValueKind]::ExpandString)
}
finally {
$key.Close()
}

Send-EnvironmentChange
return $true
}

function Main {
param([switch]$UserInstall)
Write-Host ""
Expand Down Expand Up @@ -250,24 +290,18 @@ function Main {

# Add install directory to PATH
Write-Host ""
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
if ($userPath -notlike "*$INSTALL_DIR*") {
Write-Info "Adding $INSTALL_DIR to PATH..."

try {
# Add to BEGINNING of PATH for priority over system versions
$newPath = if ($userPath) { "$INSTALL_DIR;$userPath" } else { $INSTALL_DIR }
[Environment]::SetEnvironmentVariable("Path", $newPath, "User")
Write-Success "Added to PATH (at beginning for priority)"
try {
if (Add-ToUserPath -Directory $INSTALL_DIR) {
Write-Success "Added $INSTALL_DIR to PATH (at beginning for priority)"
Write-Warning-Custom "Please restart your terminal for PATH changes to take effect"
}
catch {
Write-Warning-Custom "Failed to add to PATH automatically: $_"
Write-Info "Please add manually: $INSTALL_DIR"
else {
Write-Info "$INSTALL_DIR is already in PATH"
}
}
else {
Write-Info "$INSTALL_DIR is already in PATH"
catch {
Write-Warning-Custom "Failed to add to PATH automatically: $_"
Write-Info "Please add manually: $INSTALL_DIR"
}

# Run init to add shims directory to PATH
Expand Down
99 changes: 99 additions & 0 deletions src/internal/doctor/check_path_registry_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package doctor

import (
"strings"

"github.com/CodingWithCalvin/dtvem.cli/src/internal/path"
)

// systemPathRepairCommand rewrites the System PATH as REG_EXPAND_SZ with its
// entries unchanged. It needs an elevated PowerShell, so doctor only shows it.
const systemPathRepairCommand = `$k = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey('SYSTEM\CurrentControlSet\Control\Session Manager\Environment', $true); ` +
`$k.SetValue('Path', $k.GetValue('Path', '', 'DoNotExpandEnvironmentNames'), 'ExpandString')`

// pathRegistryTypeCheck flags a Windows PATH value that holds %VAR%
// references but is stored as REG_SZ. Windows only expands references in a
// REG_EXPAND_SZ value, so entries such as %SystemRoot%\system32 or
// %USERPROFILE%\.dotnet\tools stay literal and their directories are
// missing from PATH.
//
// dtvem releases before #295 wrote PATH as REG_SZ from `dtvem init` and the
// stale-shims fix, and .NET's Environment.SetEnvironmentVariable does too,
// so a converted value may already be in place.
//
// Rewriting the User PATH is safe to automate. The System PATH needs an
// elevated terminal, so for it doctor shows the command instead.
type pathRegistryTypeCheck struct {
readPaths func() ([]path.RegistryPath, error)
repairUser func() error
}

func newPathRegistryTypeCheck() *pathRegistryTypeCheck {
return &pathRegistryTypeCheck{
readPaths: path.RegistryPaths,
repairUser: path.RepairUserPathType,
}
}

func (pathRegistryTypeCheck) Name() string { return "path-registry-type" }

func (c pathRegistryTypeCheck) Run() Finding {
paths, err := c.readPaths()
if err != nil {
return Finding{
Severity: SeverityWarning,
Title: "Could not read PATH from the registry",
Details: []Detail{{Key: "Error", Value: err.Error()}},
Resolution: "Check that the Environment registry keys are readable.",
}
}
if len(paths) == 0 {
return Finding{OK: true, Title: "PATH is not stored in the registry on this platform"}
}

var details []Detail
userUnexpanded, systemUnexpanded := false, false
for _, p := range paths {
if !p.Unexpanded() {
continue
}
details = append(details, Detail{
Key: p.Scope + " PATH",
Value: "stored as REG_SZ, so its %VAR% entries are not expanded",
})
switch p.Scope {
case "User":
userUnexpanded = true
case "System":
systemUnexpanded = true
}
}
if len(details) == 0 {
return Finding{OK: true, Title: "PATH registry values expand their %VAR% entries"}
}

var resolution []string
var fix func() error
if userUnexpanded {
resolution = append(resolution, "Rewrite the User PATH as REG_EXPAND_SZ; its entries stay as they are.")
fix = c.repairUser
}
if systemUnexpanded {
resolution = append(resolution,
"The System PATH needs an elevated PowerShell:",
" "+systemPathRepairCommand)
}
resolution = append(resolution, "Restart your terminal afterwards.")

return Finding{
Severity: SeverityWarning,
Title: "PATH entries that use %VAR% are not being expanded",
Details: details,
Resolution: strings.Join(resolution, "\n"),
Fix: fix,
}
}

func init() {
Register(newPathRegistryTypeCheck())
}
141 changes: 141 additions & 0 deletions src/internal/doctor/check_path_registry_type_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package doctor

import (
"errors"
"strings"
"testing"

"github.com/CodingWithCalvin/dtvem.cli/src/internal/path"
)

func TestPathRegistryTypeCheck(t *testing.T) {
expandable := path.RegistryPath{Expandable: true, HasReferences: true}
unexpanded := path.RegistryPath{Expandable: false, HasReferences: true}
plain := path.RegistryPath{Expandable: false, HasReferences: false}
scoped := func(scope string, p path.RegistryPath) path.RegistryPath {
p.Scope = scope
return p
}

tests := []struct {
name string
paths []path.RegistryPath
wantOK bool
wantFixable bool
wantDetails []string
wantInResolve []string
}{
{
name: "not Windows",
paths: nil,
wantOK: true,
},
{
name: "both expandable",
paths: []path.RegistryPath{scoped("User", expandable), scoped("System", expandable)},
wantOK: true,
},
{
name: "REG_SZ without references is fine",
paths: []path.RegistryPath{scoped("User", plain), scoped("System", expandable)},
wantOK: true,
},
{
name: "User PATH unexpanded",
paths: []path.RegistryPath{scoped("User", unexpanded), scoped("System", expandable)},
wantFixable: true,
wantDetails: []string{"User PATH"},
wantInResolve: []string{"Rewrite the User PATH as REG_EXPAND_SZ"},
},
{
name: "System PATH unexpanded",
paths: []path.RegistryPath{scoped("User", expandable), scoped("System", unexpanded)},
wantFixable: false,
wantDetails: []string{"System PATH"},
wantInResolve: []string{"elevated PowerShell", "'ExpandString'"},
},
{
name: "both unexpanded",
paths: []path.RegistryPath{scoped("User", unexpanded), scoped("System", unexpanded)},
wantFixable: true,
wantDetails: []string{"User PATH", "System PATH"},
wantInResolve: []string{"Rewrite the User PATH", "elevated PowerShell"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := pathRegistryTypeCheck{
readPaths: func() ([]path.RegistryPath, error) { return tt.paths, nil },
repairUser: func() error { return nil },
}

f := c.Run()
if f.OK != tt.wantOK {
t.Fatalf("Run().OK = %v, want %v (%+v)", f.OK, tt.wantOK, f)
}
if tt.wantOK {
return
}
if f.Severity != SeverityWarning {
t.Errorf("Severity = %v, want warning", f.Severity)
}
if f.Fixable() != tt.wantFixable {
t.Errorf("Fixable() = %v, want %v", f.Fixable(), tt.wantFixable)
}
var gotDetails []string
for _, d := range f.Details {
gotDetails = append(gotDetails, d.Key)
}
if strings.Join(gotDetails, ",") != strings.Join(tt.wantDetails, ",") {
t.Errorf("Details = %v, want %v", gotDetails, tt.wantDetails)
}
for _, s := range tt.wantInResolve {
if !strings.Contains(f.Resolution, s) {
t.Errorf("Resolution %q does not mention %q", f.Resolution, s)
}
}
})
}
}

func TestPathRegistryTypeCheck_FixRepairsUserPath(t *testing.T) {
repaired := false
c := pathRegistryTypeCheck{
readPaths: func() ([]path.RegistryPath, error) {
return []path.RegistryPath{{Scope: "User", HasReferences: true}}, nil
},
repairUser: func() error { repaired = true; return nil },
}

f := c.Run()
if !f.Fixable() {
t.Fatal("finding is not fixable")
}
if err := f.Fix(); err != nil {
t.Fatalf("Fix() error: %v", err)
}
if !repaired {
t.Error("Fix() did not repair the User PATH")
}
}

func TestPathRegistryTypeCheck_ReadError(t *testing.T) {
c := pathRegistryTypeCheck{
readPaths: func() ([]path.RegistryPath, error) { return nil, errors.New("access denied") },
}

f := c.Run()
if f.OK || f.Severity != SeverityWarning || f.Fixable() {
t.Errorf("Run() = %+v, want an unfixable warning", f)
}
}

func TestPathRegistryTypeCheck_Registered(t *testing.T) {
for _, c := range All() {
if c.Name() == "path-registry-type" {
return
}
}
t.Error("path-registry-type check is not registered")
}
15 changes: 15 additions & 0 deletions src/internal/path/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ import (
// line above, so we never touch lines the user wrote themselves.
const dtvemMarkerComment = "# Added by dtvem"

// RegistryPath describes how a PATH value is stored in the Windows
// registry. See RegistryPaths.
type RegistryPath struct {
Scope string // "User" or "System"
Expandable bool // stored as REG_EXPAND_SZ
HasReferences bool // contains %VAR% references
}

// Unexpanded reports whether the value's %VAR% references are left
// literal, and so their directories are missing from PATH, because it is
// stored as REG_SZ rather than REG_EXPAND_SZ.
func (p RegistryPath) Unexpanded() bool {
return p.HasReferences && !p.Expandable
}

// IsInPath checks if a directory is in the system PATH
func IsInPath(dir string) bool {
pathEnv := os.Getenv("PATH")
Expand Down
12 changes: 12 additions & 0 deletions src/internal/path/path_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,15 @@ func containsPathModification(configFile, shimsDir string) bool {

return false
}

// RegistryPaths reports how the PATH values are stored in the Windows
// registry. There is no such registry on this platform.
func RegistryPaths() ([]RegistryPath, error) {
return nil, nil
}

// RepairUserPathType rewrites the User PATH as REG_EXPAND_SZ on Windows.
// There is no such registry on this platform.
func RepairUserPathType() error {
return fmt.Errorf("the PATH registry only exists on Windows")
}
Loading
Loading