Skip to content
Open
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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,15 @@ jobs:
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
go test ./internal/sdk -run '^TestInstall' -count=1 -v
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
- name: Activate vfox in PowerShell with a non-ASCII PATH
shell: pwsh
env:
# The test changes nothing in the registry, but its fixture selects SDKs
# like the build job does, so it needs the same runner.
VFOX_TEST_WINDOWS_REGISTRY: '1'
run: |
go test ./cmd/commands -run '^TestPowerShellSessionKeepsNonASCIIPath$' -count=1 -v
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

nushell:
name: Nushell integration
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ Keep imports acyclic. Shared utilities must not depend on SDK, plugin, environme
- Register command definitions in [cmd/cmd.go](cmd/cmd.go). Helpers and platform-specific files may share a command implementation.
- Use `CategorySDK` or `CategoryPlugin` for commands belonging to those groups. Utility commands may be uncategorized.
- Keep `activate` hidden unless CLI visibility changes are part of the task. Users still invoke `vfox activate <shell>` during shell setup.
- Activation and environment output is evaluated by the shell; keep unrelated output out of generated shell code. Diagnostics belong to `use` and `doctor`, never to `activate` or `env`; `env` runs at every prompt, so it gets no new checks.
- Activation and environment output is evaluated by the shell; keep unrelated output out of generated shell code. PowerShell output stays ASCII (`PowerShellEscape` in [powershell.go](internal/shell/powershell.go)): PowerShell decodes a native command's output with the console's code page before the hook can switch the console to UTF-8. Diagnostics belong to `use` and `doctor`, never to `activate` or `env`; `env` runs at every prompt, so it gets no new checks.
- SDK argument parsing is command-specific. Check the relevant parser before changing version prefixes, `@latest`, or `exec` arguments and its `--` separator.
- `link` registers an SDK that already exists (`<sdk>@<version> <path>`, or `--detect` through the plugin's `Detect` hook); the version name is used as written. `doctor` prints findings or `--json` and exits with status 1 only for error findings; `doctor --adopt` writes missing receipts. `alias` edits only the `aliases` key of the user config through `config.AliasFile`, which is built on `config.File` (the YAML-document editor that `vfox config` uses for any key): never save a whole `Config` for that, which would write every default as a section the user declared, and keep sections the file does not declare undeclared so they follow the shared config. `install --reinstall` needs the exact installed version and refuses linked ones.
- Aliases are resolved for command-line input only (`use`, `uninstall`, `info`, `exec`, `doctor`), through `Manager.ResolveAlias`: an installed version of the same name wins. Never resolve them in version files or in `env`/`activate`.
Expand Down
21 changes: 21 additions & 0 deletions cmd/commands/activate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,27 @@ func TestRenderActivateScriptForIDEEnvironmentResolutionSkipsHookSessionState(t
}
}

func TestRenderActivateScriptPowerShellIsASCII(t *testing.T) {
path := `C:\用户\bin;C:\Windows`
got, err := renderActivateScript("pwsh", "C:/用户/vfox.exe", nil, env.Vars{
"PATH": &path,
env.OrigPathFlag: &path,
}, false)
if err != nil {
t.Fatalf("renderActivateScript() failed: %v", err)
}
// PowerShell reads what `vfox activate` prints with the console's code page,
// which is not UTF-8 on a Chinese Windows, before the script can change it.
for i := 0; i < len(got); i++ {
if got[i] >= 0x80 {
t.Fatalf("the PowerShell activation script has a non-ASCII byte at %d:\n%s", i, got)
}
}
if !strings.Contains(got, "$env:PATH=") {
t.Fatalf("the PowerShell activation script does not set PATH:\n%s", got)
}
}

func TestRenderActivateScriptPassesNushellConfigPath(t *testing.T) {
configDir := t.TempDir()

Expand Down
7 changes: 6 additions & 1 deletion cmd/commands/nushell_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,12 @@ func buildVfox(t *testing.T) string {
if err != nil {
t.Fatalf("the go command is needed to build vfox for this test: %v", err)
}
bin := filepath.Join(t.TempDir(), "vfox")
name := "vfox"
if runtime.GOOS == "windows" {
// go build takes an explicit -o name as it is, and Windows runs only .exe files.
name += ".exe"
}
bin := filepath.Join(t.TempDir(), name)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, goTool, "build", "-buildvcs=false", "-o", bin, ".")
Expand Down
107 changes: 107 additions & 0 deletions cmd/commands/powershell_cli_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package commands

import (
"context"
"encoding/base64"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"

"github.com/version-fox/vfox/internal/shared/util"
)

// powerShellProbe runs in PowerShell with its console reading native output as
// code page 936, the one Simplified Chinese Windows uses. It activates vfox the
// way the README does, then prints the session as base64, so that the report
// does not depend on the code page either. The errors come last: they may span
// lines.
const powerShellProbe = `
$ErrorActionPreference = 'Continue'
try { [System.Text.Encoding]::RegisterProvider([System.Text.CodePagesEncodingProvider]::Instance) } catch { }
[Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding(936)
$codePage = [Console]::OutputEncoding.CodePage
$Error.Clear()
Invoke-Expression "$(& $env:VFOX_TEST_VFOX activate pwsh)"
$nl = [char]10
$hookErrors = ($Error | ForEach-Object { $_.ToString() }) -join $nl
$report = 'CP=' + $codePage + $nl + 'PATH=' + $env:PATH + $nl + 'INITIALIZED=' + $env:__VFOX_INITIALIZED + $nl + 'ERRORS=' + $hookErrors
[Console]::Out.Write([System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($report)))
`

// powerShells returns the PowerShells that run the session test: Windows
// PowerShell and PowerShell 7, whichever is installed.
func powerShells(t *testing.T) []string {
t.Helper()
var found []string
for _, name := range []string{"powershell", "pwsh"} {
if path, err := exec.LookPath(name); err == nil {
found = append(found, path)
}
}
if len(found) == 0 {
t.Skip("neither powershell nor pwsh is installed")
}
return found
}

// probeFields decodes the report that powerShellProbe prints.
func probeFields(t *testing.T, r cliResult) (codePage, path, initialized, hookErrors string) {
t.Helper()
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(r.stdout))
if err != nil {
t.Fatalf("the probe printed no base64 report (%v)\nstdout: %q\nstderr: %s", err, r.stdout, r.stderr)
}
lines := strings.SplitN(string(raw), "\n", 4)
if len(lines) != 4 {
t.Fatalf("the probe report has %d lines, want 4:\n%s\nstderr: %s", len(lines), raw, r.stderr)
}
return strings.TrimPrefix(lines[0], "CP="), strings.TrimPrefix(lines[1], "PATH="),
strings.TrimPrefix(lines[2], "INITIALIZED="), strings.TrimPrefix(lines[3], "ERRORS=")
}

// TestPowerShellSessionKeepsNonASCIIPath activates vfox in PowerShell whose
// console reads native output with a Chinese code page, and checks that the
// session starts with the non-ASCII PATH entry it was given. The vfox executable
// lives below a non-ASCII directory too, as it does for a user whose name is not
// ASCII. Windows only: the console code page is a Windows setting.
func TestPowerShellSessionKeepsNonASCIIPath(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("the console code page that garbles the output is a Windows setting")
}
f := newCLIFixture(t, false)
vfox := buildVfox(t)
vfoxDir := filepath.Join(f.root, "中文", "bin")
must(t, os.MkdirAll(vfoxDir, 0755))
vfoxExe := filepath.Join(vfoxDir, "vfox.exe")
must(t, os.Rename(vfox, vfoxExe))
entry := filepath.Join(f.root, "用户工具", "bin")
must(t, os.MkdirAll(entry, 0755))
f.env["PATH"] = pathList(entry, f.system)
f.env["VFOX_TEST_VFOX"] = vfoxExe

for _, shell := range powerShells(t) {
t.Run(filepath.Base(shell), func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
r := runCommand(t, f.command(ctx, shell, "-NoProfile", "-NonInteractive", "-EncodedCommand", util.PowerShellEncodedCommand(powerShellProbe)))
codePage, path, initialized, hookErrors := probeFields(t, r)
if codePage != "936" {
t.Fatalf("the console took code page %q, not 936, so the output is not read the way a Chinese Windows reads it\nstderr: %s", codePage, r.stderr)
}
if !slices.Contains(strings.Split(path, string(os.PathListSeparator)), entry) {
t.Errorf("the session starts with PATH %q, which lost the entry %q", path, entry)
}
if initialized != "1" {
t.Errorf("the hook did not finish (__VFOX_INITIALIZED = %q)", initialized)
}
if hookErrors != "" {
t.Errorf("activating vfox reported errors:\n%s", hookErrors)
}
})
}
}
141 changes: 52 additions & 89 deletions internal/shell/powershell.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
package shell

import (
"encoding/base64"
"fmt"
"regexp"
"strings"

"github.com/version-fox/vfox/internal/env"
)
Expand All @@ -29,13 +31,16 @@ type pwsh struct{}
// Pwsh shell instance
var Pwsh Shell = pwsh{}

// hook is a template. {{.EnvContent}} is the export of the environment, and
// {{.SelfPath}} stands for the vfox executable: Activate puts that in as a
// PowerShell expression, see PowerShellEscape.
const hook = `
{{.EnvContent}}

<#
Due to a bug in PowerShell, we have to cleanup first when the shell open.
#>
& '{{.SelfPath}}' env --cleanup 2>$null | Out-Null;
& {{.SelfPath}} env --cleanup 2>$null | Out-Null;

$env:__VFOX_PID = $pid;
$env:__VFOX_SHELL = 'pwsh';
Expand All @@ -46,21 +51,27 @@ if (-not $env:__VFOX_INITIALIZED) {
$env:__VFOX_INITIALIZED = '1';
$originalPrompt = $function:prompt;
function prompt {
$export = &"{{.SelfPath}}" env -s pwsh;
$export = & {{.SelfPath}} env -s pwsh;
if ($export) {
Invoke-Expression -Command $export;
}
&$originalPrompt;
}

Register-EngineEvent -SourceIdentifier VfoxCleanup -Action {
&"{{.SelfPath}}" env --cleanup
& {{.SelfPath}} env --cleanup
} | Out-Null;
}
`

func (sh pwsh) Activate(config ActivateConfig) (string, error) {
return hook, nil
// text/template reads "{{" in a path as the start of an action. A path that
// has it takes the encoded form, which has no braces.
self := PowerShellEscape(config.SelfPath)
if strings.Contains(config.SelfPath, "{{") {
self = powerShellUTF8(config.SelfPath)
}
return strings.ReplaceAll(hook, "{{.SelfPath}}", self), nil
}

func (sh pwsh) Export(e env.Vars) (out string) {
Expand All @@ -75,103 +86,55 @@ func (sh pwsh) Export(e env.Vars) (out string) {
}

func (sh pwsh) export(key, value string) string {
value = sh.escape(value)
if !regexp.MustCompile(`'.*'`).MatchString(value) {
value = fmt.Sprintf("'%s'", value)
if !isEnvName(key) {
return fmt.Sprintf("[System.Environment]::SetEnvironmentVariable(%s,%s);", PowerShellEscape(key), PowerShellEscape(value))
}
return fmt.Sprintf("$env:%s=%s;", sh.escape(key), value)
return fmt.Sprintf("$env:%s=%s;", key, PowerShellEscape(value))
}

func (sh pwsh) unset(key string) string {
return fmt.Sprintf("Remove-Item -Path 'env:/%s';", sh.escape(key))
if !isEnvName(key) {
// From PowerShell 7.5 on, a $null value only empties the variable;
// [NullString]::Value removes it.
return fmt.Sprintf("[System.Environment]::SetEnvironmentVariable(%s,[NullString]::Value);", PowerShellEscape(key))
}
return fmt.Sprintf("Remove-Item -Path 'env:/%s';", key)
}

func (pwsh) escape(str string) string {
return PowerShellEscape(str)
// envName matches the names that $env: can spell without quotes.
var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

func isEnvName(key string) bool {
return envName.MatchString(key)
}

// PowerShellEscape returns a PowerShell expression that evaluates to str.
//
// PowerShell decodes the output of a native command with the console's code
// page, and the hook can switch the console to UTF-8 only after the output of
// `vfox activate` has been decoded. On many Windows systems that page is not
// UTF-8, so the output must be ASCII. Printable ASCII becomes a single-quoted
// literal, in which the only escape is a doubled quote. Anything else becomes
// the base64 of its UTF-8 bytes, which PowerShell decodes when the expression runs.
func PowerShellEscape(str string) string {
if str == "" {
return "''"
}
in := []byte(str)
out := ""
i := 0
l := len(in)
escape := false

hex := func(char byte) {
escape = true
out += fmt.Sprintf("\\x%02x", char)
}

backslash := func(char byte) {
escape = true
out += string([]byte{BACKTICK, char})
}

escaped := func(str string) {
escape = true
out += str
}

quoted := func(char byte) {
escape = true
out += string([]byte{char})
if isPrintableASCII(str) {
return "'" + strings.ReplaceAll(str, "'", "''") + "'"
}
return powerShellUTF8(str)
}

literal := func(char byte) {
out += string([]byte{char})
}
// powerShellUTF8 is the expression that decodes the base64 of the UTF-8 bytes of str.
func powerShellUTF8(str string) string {
return "([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('" +
base64.StdEncoding.EncodeToString([]byte(str)) + "')))"
}

for i < l {
char := in[i]
switch {
case char == ACK:
hex(char)
case char == TAB:
escaped("`t")
case char == LF:
escaped("`n")
case char == CR:
escaped("`r")
case char <= US:
hex(char)
// case char <= AMPERSTAND:
// quoted(char)
case char == SINGLE_QUOTE:
backslash(char)
case char <= PLUS:
quoted(char)
case char <= NINE:
literal(char)
// case char <= QUESTION:
// quoted(char)
case char <= UPPERCASE_Z:
literal(char)
// case char == OPEN_BRACKET:
// quoted(char)
// case char == BACKSLASH:
// quoted(char)
case char == UNDERSCORE:
literal(char)
// case char <= CLOSE_BRACKET:
// quoted(char)
// case char <= BACKTICK:
// quoted(char)
// case char <= TILDA:
// quoted(char)
case char == DEL:
hex(char)
default:
quoted(char)
// isPrintableASCII reports whether every byte of str is a printable ASCII character.
func isPrintableASCII(str string) bool {
for i := 0; i < len(str); i++ {
if str[i] < 0x20 || str[i] > 0x7e {
return false
}
i++
}

if escape {
out = "'" + out + "'"
}

return out
return true
}
Loading
Loading