From 6866d8469bd916d2d71a6172f25487b4eec88344 Mon Sep 17 00:00:00 2001 From: aooohan Date: Sat, 12 Sep 2026 15:40:47 +0800 Subject: [PATCH 1/6] fix(sdk): recover incomplete installations safely Keep pending installs out of runtime resolution, preserve final plugin paths, and serialize retries with an OS file lock. Fixes #252 --- AGENTS.md | 1 + internal/sdk/install_lock.go | 41 +++++ internal/sdk/install_lock_unix.go | 29 ++++ internal/sdk/install_lock_windows.go | 29 ++++ internal/sdk/install_test.go | 235 +++++++++++++++++++++++++++ internal/sdk/install_windows_test.go | 75 +++++++++ internal/sdk/sdk.go | 85 +++++++--- 7 files changed, 473 insertions(+), 22 deletions(-) create mode 100644 internal/sdk/install_lock.go create mode 100644 internal/sdk/install_lock_unix.go create mode 100644 internal/sdk/install_lock_windows.go create mode 100644 internal/sdk/install_test.go create mode 100644 internal/sdk/install_windows_test.go diff --git a/AGENTS.md b/AGENTS.md index 58c3f1cb..9823ed73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,6 +77,7 @@ Keep imports acyclic. Shared utilities must not depend on SDK, plugin, environme ## SDK and plugin behavior - Installation calls `PreInstall`, prepares the main runtime and additions, then calls optional `PostInstall`. Preserve cleanup of partial directories created by a failed attempt. Uninstallation and its optional `PreUninstall` hook belong to SDK. +- Installation keeps the final payload path stable for plugin-generated paths. `Install` uses a sibling `.installing` marker until `PostInstall` completes and a sibling `.lock` file for cross-process exclusion. Preserve interrupted-install retry and legacy marker-free payloads; do not unlink the lock file after releasing it. - Version resolution calls optional `PreUse` first. If no version is returned, use the existing exact-installed and prefix-matching logic. `IsNoResultProvided(err)` permits fallback; actual hook errors propagate. `UseWithConfig` checks that the resolved version is installed before applying scope state. - `Current` checks the highest-priority configured version. Activation/export uses the separate installed-version fallback in [tool_resolution.go](cmd/commands/tool_resolution.go); preserve the caller-specific behavior. - `EnvKeysForScope` passes scope link paths to the plugin and does not create links. Use the platform helpers in `env` to create/remove directory links for the main runtime and additions, preserving links that already point to the correct target. diff --git a/internal/sdk/install_lock.go b/internal/sdk/install_lock.go new file mode 100644 index 00000000..f08d0fff --- /dev/null +++ b/internal/sdk/install_lock.go @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sdk + +import ( + "fmt" + "os" + "path/filepath" +) + +// acquireInstallLock serializes attempts across SDK instances and processes. +// The file is kept after Close: unlinking it could let another process lock a +// different inode. Closing the descriptor (including process exit) releases it. +func acquireInstallLock(path string) (*os.File, error) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return nil, fmt.Errorf("create installation root: %w", err) + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return nil, fmt.Errorf("open installation lock: %w", err) + } + if err := lockInstallFile(file); err != nil { + _ = file.Close() // No lock was acquired; preserve the locking error. + return nil, fmt.Errorf("installation is in progress or cannot be locked: %w", err) + } + return file, nil +} diff --git a/internal/sdk/install_lock_unix.go b/internal/sdk/install_lock_unix.go new file mode 100644 index 00000000..2a261a92 --- /dev/null +++ b/internal/sdk/install_lock_unix.go @@ -0,0 +1,29 @@ +//go:build !windows + +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sdk + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func lockInstallFile(file *os.File) error { + return unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) +} diff --git a/internal/sdk/install_lock_windows.go b/internal/sdk/install_lock_windows.go new file mode 100644 index 00000000..3e6e1571 --- /dev/null +++ b/internal/sdk/install_lock_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sdk + +import ( + "os" + + "golang.org/x/sys/windows" +) + +func lockInstallFile(file *os.File) error { + return windows.LockFileEx(windows.Handle(file.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, &windows.Overlapped{}) +} diff --git a/internal/sdk/install_test.go b/internal/sdk/install_test.go new file mode 100644 index 00000000..dce4262c --- /dev/null +++ b/internal/sdk/install_test.go @@ -0,0 +1,235 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sdk + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/version-fox/vfox/internal/plugin" +) + +type installTestPlugin struct { + plugin.Plugin + postInstall func(*plugin.PostInstallHookCtx) error +} + +func (p *installTestPlugin) PreInstall(ctx *plugin.PreInstallHookCtx) (*plugin.PreInstallHookResult, error) { + return &plugin.PreInstallHookResult{PreInstallPackageItem: &plugin.PreInstallPackageItem{Version: ctx.Version}}, nil +} + +func (p *installTestPlugin) HasFunction(name string) bool { return name == "PostInstall" } + +func (p *installTestPlugin) PostInstall(ctx *plugin.PostInstallHookCtx) error { + if p.postInstall != nil { + return p.postInstall(ctx) + } + return nil +} + +func newInstallTestSDK(root string, post func(*plugin.PostInstallHookCtx) error) *impl { + return &impl{ + Name: "test-sdk", InstallPath: root, + plugin: &plugin.Wrapper{ + Metadata: &plugin.Metadata{Name: "test-sdk"}, + Plugin: &installTestPlugin{postInstall: post}, + }, + } +} + +func TestInstallRejectsIncompleteRuntimes(t *testing.T) { + for _, tc := range []struct { + name string + payload bool + pending bool + want bool + }{ + {name: "empty version directory"}, + {name: "unfinished payload", payload: true, pending: true}, + {name: "legacy installation", payload: true, want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + s := newInstallTestSDK(t.TempDir(), nil) + path := s.packagePath("1.0") + if tc.payload { + path = filepath.Join(path, "test-sdk-1.0") + } + if err := os.MkdirAll(path, 0755); err != nil { + t.Fatal(err) + } + if tc.pending { + if err := os.WriteFile(filepath.Join(s.InstallPath, ".v-1.0.installing"), nil, 0600); err != nil { + t.Fatal(err) + } + } + if got := s.CheckRuntimeExist("1.0"); got != tc.want { + t.Errorf("CheckRuntimeExist = %v, want %v", got, tc.want) + } + if got := len(s.InstalledList()) > 0; got != tc.want { + t.Errorf("listed installation = %v, want %v", got, tc.want) + } + _, err := s.GetRuntimePackage("1.0") + if (err == nil) != tc.want { + t.Errorf("GetRuntimePackage error = %v, want installed %v", err, tc.want) + } + }) + } +} + +func TestInstallBecomesAvailableAfterPostInstall(t *testing.T) { + var s *impl + s = newInstallTestSDK(t.TempDir(), func(ctx *plugin.PostInstallHookCtx) error { + if s.CheckRuntimeExist("1.0") || len(s.InstalledList()) != 0 { + t.Error("runtime is exposed before PostInstall completes") + } + if _, err := s.GetRuntimePackage("1.0"); !errors.Is(err, ErrRuntimeNotFound) { + t.Errorf("unfinished runtime lookup error = %v", err) + } + want := filepath.Join(s.packagePath("1.0"), "test-sdk-1.0") + if ctx.SdkInfo["test-sdk"].Path != want { + t.Fatal("PostInstall must receive the final path for embedded paths/shebangs") + } + return os.WriteFile(filepath.Join(want, "installed"), []byte(want), 0600) + }) + if err := s.Install("1.0"); err != nil { + t.Fatal(err) + } + if !s.CheckRuntimeExist("1.0") || len(s.InstalledList()) != 1 { + t.Fatal("completed installation is not available") + } +} + +func TestInstallFailureCanRetry(t *testing.T) { + wantErr := errors.New("post-install failed") + s := newInstallTestSDK(t.TempDir(), func(ctx *plugin.PostInstallHookCtx) error { return wantErr }) + if err := s.Install("1.0"); !errors.Is(err, wantErr) { + t.Fatalf("Install error = %v, want wrapped hook error", err) + } + if _, err := os.Stat(s.packagePath("1.0")); !os.IsNotExist(err) { + t.Fatalf("failed installation was not removed: %v", err) + } + s.plugin.Plugin.(*installTestPlugin).postInstall = nil + if err := s.Install("1.0"); err != nil { + t.Fatal(err) + } + if !s.CheckRuntimeExist("1.0") { + t.Fatal("retry did not finish installation") + } +} + +func TestInstallSerializesSameVersion(t *testing.T) { + root := t.TempDir() + started, finish := make(chan struct{}), make(chan struct{}) + first := newInstallTestSDK(root, func(ctx *plugin.PostInstallHookCtx) error { + close(started) + <-finish + return nil + }) + done := make(chan error, 1) + go func() { done <- first.Install("1.0") }() + <-started + second := newInstallTestSDK(root, nil) + err := second.Install("1.0") + close(finish) + if firstErr := <-done; firstErr != nil { + t.Fatal(firstErr) + } + if err == nil { + t.Fatal("second install must report the active installation, not claim success") + } + if err := second.Install("1.0"); err != nil { + t.Fatalf("completed installation should be reusable: %v", err) + } +} + +// The helper is a separate process so abrupt termination skips all install defers. +func TestInstallInterruptedHelper(t *testing.T) { + root := os.Getenv("VFOX_INSTALL_INTERRUPTION_TEST") + if root == "" { + return + } + s := newInstallTestSDK(root, func(ctx *plugin.PostInstallHookCtx) error { + if err := os.WriteFile(filepath.Join(ctx.SdkInfo["test-sdk"].Path, "partial"), nil, 0600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, "ready"), nil, 0600); err != nil { + return err + } + time.Sleep(time.Hour) + return nil + }) + if err := s.Install("1.0"); err != nil { + t.Fatal(err) + } +} + +func TestInstallRecoversAfterProcessTermination(t *testing.T) { + root := t.TempDir() + cmd := exec.Command(os.Args[0], "-test.run=^TestInstallInterruptedHelper$") + cmd.Env = append(os.Environ(), "VFOX_INSTALL_INTERRUPTION_TEST="+root) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill() }) + deadline := time.Now().Add(15 * time.Second) + for { + if _, err := os.Stat(filepath.Join(root, "ready")); err == nil { + break + } + if time.Now().After(deadline) { + _ = cmd.Process.Kill() + _ = cmd.Wait() + t.Fatal("child did not reach PostInstall") + } + time.Sleep(10 * time.Millisecond) + } + active := newInstallTestSDK(root, nil) + if err := active.Install("1.0"); err == nil { + t.Error("second process did not reject an active installation") + } + if _, err := os.Stat(filepath.Join(active.packagePath("1.0"), "test-sdk-1.0", "partial")); err != nil { + t.Errorf("second process disturbed the active payload: %v", err) + } + if err := cmd.Process.Kill(); err != nil { + t.Fatal(err) + } + if err := cmd.Wait(); err == nil { + t.Fatal("terminated install unexpectedly succeeded") + } + called := false + s := newInstallTestSDK(root, func(ctx *plugin.PostInstallHookCtx) error { + called = true + _, err := os.Stat(filepath.Join(ctx.SdkInfo["test-sdk"].Path, "partial")) + if !os.IsNotExist(err) { + t.Fatalf("retry kept interrupted payload: %v", err) + } + return nil + }) + if s.CheckRuntimeExist("1.0") { + t.Error("terminated install is marked installed") + } + if err := s.Install("1.0"); err != nil { + t.Fatal(err) + } + if !called || !s.CheckRuntimeExist("1.0") { + t.Fatal("interrupted install was not retried successfully") + } +} diff --git a/internal/sdk/install_windows_test.go b/internal/sdk/install_windows_test.go new file mode 100644 index 00000000..ce04578f --- /dev/null +++ b/internal/sdk/install_windows_test.go @@ -0,0 +1,75 @@ +//go:build windows + +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package sdk + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "golang.org/x/sys/windows" + + "github.com/version-fox/vfox/internal/plugin" +) + +func TestInstallKeepsMarkerWhenWindowsPayloadIsLocked(t *testing.T) { + handle := windows.InvalidHandle + defer func() { + if handle != windows.InvalidHandle { + _ = windows.CloseHandle(handle) + } + }() + hookErr := errors.New("installer child failed with an open payload") + s := newInstallTestSDK(t.TempDir(), func(ctx *plugin.PostInstallHookCtx) error { + path := filepath.Join(ctx.SdkInfo["test-sdk"].Path, "locked.exe") + if err := os.WriteFile(path, nil, 0600); err != nil { + return err + } + name, err := windows.UTF16PtrFromString(path) + if err != nil { + return err + } + handle, err = windows.CreateFile(name, windows.GENERIC_READ, windows.FILE_SHARE_READ, nil, windows.OPEN_EXISTING, windows.FILE_ATTRIBUTE_NORMAL, 0) + if err != nil { + return err + } + return hookErr + }) + if err := s.Install("1.0"); !errors.Is(err, hookErr) { + t.Fatalf("Install error = %v", err) + } + if _, err := os.Stat(filepath.Join(s.InstallPath, ".v-1.0.installing")); err != nil { + t.Fatalf("failed cleanup lost its marker: %v", err) + } + if s.CheckRuntimeExist("1.0") || len(s.InstalledList()) != 0 { + t.Fatal("locked partial payload is exposed as installed") + } + if err := windows.CloseHandle(handle); err != nil { + t.Fatal(err) + } + handle = windows.InvalidHandle + s.plugin.Plugin.(*installTestPlugin).postInstall = nil + if err := s.Install("1.0"); err != nil { + t.Fatal(err) + } + if !s.CheckRuntimeExist("1.0") { + t.Fatal("retry did not complete") + } +} diff --git a/internal/sdk/sdk.go b/internal/sdk/sdk.go index b15edf2d..c1440e6c 100644 --- a/internal/sdk/sdk.go +++ b/internal/sdk/sdk.go @@ -24,15 +24,14 @@ import ( "net/http" "net/url" "os" - "os/signal" "path/filepath" "runtime" "sort" "strings" - "syscall" "github.com/pterm/pterm" "github.com/schollz/progressbar/v3" + "github.com/version-fox/vfox/internal/env" "github.com/version-fox/vfox/internal/pathmeta" "github.com/version-fox/vfox/internal/plugin" @@ -150,7 +149,7 @@ func (b *impl) Available(args []string) ([]*AvailableRuntimePackage, error) { // Install installs a specific version of the SDK. // For main runtime, it will be installed to {InstallPath}/v-{main_version}/{main_name}-{main_version} // For additional runtimes, it will be installed to {InstallPath}/v-{main_version}/add-{addition_name}-{addition_version} -func (b *impl) Install(version Version) error { +func (b *impl) Install(version Version) (installErr error) { label := b.Label(version) logger.Debugf("Installing SDK: %s\n", label) @@ -172,7 +171,7 @@ func (b *impl) Install(version Version) error { logger.Debugf("PreInstall hook failed for %s: %v\n", label, err) return fmt.Errorf("plugin [PreInstall] method error: %w", err) } - if installInfo == nil { + if installInfo == nil || installInfo.PreInstallPackageItem == nil { return fmt.Errorf("no information about the current version") } @@ -184,33 +183,44 @@ func (b *impl) Install(version Version) error { // for example, latest is resolved to a specific version number. label = b.Label(sdkVersion) logger.Debugf("Resolved version: %s\n", sdkVersion) + newDirPath := b.packagePath(sdkVersion) + lock, err := acquireInstallLock(filepath.Join(b.InstallPath, ".v-"+string(sdkVersion)+".lock")) + if err != nil { + return fmt.Errorf("cannot install %s: %w", label, err) + } + defer func() { + if err := lock.Close(); err != nil { + installErr = errors.Join(installErr, fmt.Errorf("close installation lock: %w", err)) + } + }() + // Recheck while holding the lock, before cleaning an interrupted attempt. if b.CheckRuntimeExist(sdkVersion) { fmt.Printf("%s is already installed\n", label) logger.Debugf("SDK %s already exists after version resolution\n", label) return nil } success := false - newDirPath := b.packagePath(sdkVersion) logger.Debugf("Installing to path: %s\n", newDirPath) - - sigs := make(chan os.Signal, 1) - - signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) - - go func() { - _ = <-sigs - if !success { - _ = os.RemoveAll(newDirPath) - } - os.Exit(0) - }() - - // Delete directory after failed installation + // Keep the marker outside the payload so failed cleanup cannot erase it + // before removing files still held open by a child process on Windows. + markerPath := b.installMarkerPath(sdkVersion) + if err := os.WriteFile(markerPath, nil, 0600); err != nil { + return fmt.Errorf("mark installation in progress: %w", err) + } + // Ordinary errors clean up immediately. Process termination leaves the + // marker behind and releases the OS lock; the next attempt can retry. defer func() { if !success { - _ = os.RemoveAll(newDirPath) + if err := os.RemoveAll(newDirPath); err != nil { + installErr = errors.Join(installErr, fmt.Errorf("clean incomplete installation: %w", err)) + } else if err := os.Remove(markerPath); err != nil && !os.IsNotExist(err) { + installErr = errors.Join(installErr, fmt.Errorf("remove installation marker: %w", err)) + } } }() + if err := os.RemoveAll(newDirPath); err != nil { + return fmt.Errorf("remove interrupted installation: %w", err) + } installedPackage := make(map[string]*plugin.InstalledPackageItem) path, err := b.preInstallSdk(mainSdk, filepath.Join(newDirPath, b.runtimePathDirName(true, mainSdk))) @@ -250,6 +260,9 @@ func (b *impl) Install(version Version) error { return fmt.Errorf("plugin [PostInstall] method error: %w", err) } } + if err := os.Remove(markerPath); err != nil { + return fmt.Errorf("finish installation: %w", err) + } success = true pterm.Printf("Install %s success! \n", pterm.LightGreen(label)) logger.Debugf("SDK %s installed successfully\n", label) @@ -689,7 +702,10 @@ func (b *impl) InstalledList() []Version { } for _, d := range dir { if d.IsDir() && strings.HasPrefix(d.Name(), "v-") { - versions = append(versions, Version(strings.TrimPrefix(d.Name(), "v-"))) + version := Version(strings.TrimPrefix(d.Name(), "v-")) + if b.CheckRuntimeExist(version) { + versions = append(versions, version) + } } } sort.Slice(versions, func(i, j int) bool { @@ -860,6 +876,9 @@ func (b *impl) createDirSymlinks(runtime *Runtime, targetDir string) error { } func (b *impl) GetRuntimePackage(version Version) (*RuntimePackage, error) { + if b.hasPendingInstall(version) { + return nil, ErrRuntimeNotFound + } versionPath := b.packagePath(version) items := make(map[string]*Runtime) dir, err := os.ReadDir(versionPath) @@ -899,11 +918,33 @@ func (b *impl) GetRuntimePackage(version Version) (*RuntimePackage, error) { Additions: additions, PackagePath: versionPath, } + if b.hasPendingInstall(version) { + return nil, ErrRuntimeNotFound + } return p2, nil } func (b *impl) CheckRuntimeExist(version Version) bool { - return util.FileExists(b.packagePath(version)) + if b.hasPendingInstall(version) { + return false + } + // Older installations have no marker. Keep those valid, but do not accept + // an empty version directory without the SDK's main payload directory. + name := b.Name + if b.plugin != nil { + name = b.plugin.Name + } + info, err := os.Stat(filepath.Join(b.packagePath(version), name+"-"+string(version))) + return err == nil && info.IsDir() && !b.hasPendingInstall(version) +} + +func (b *impl) installMarkerPath(version Version) string { + return filepath.Join(b.InstallPath, ".v-"+string(version)+".installing") +} + +func (b *impl) hasPendingInstall(version Version) bool { + _, err := os.Lstat(b.installMarkerPath(version)) + return !os.IsNotExist(err) } func (b *impl) packagePath(version Version) string { From 1653d16f85c22056982d775a3e785df866d25d1a Mon Sep 17 00:00:00 2001 From: aooohan Date: Sat, 12 Sep 2026 15:43:37 +0800 Subject: [PATCH 2/6] fix(windows): serialize MSYS2 zsh paths as POSIX paths Fixes #490 --- internal/env/env_win.go | 3 +- internal/env/path_windows_test.go | 85 +++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 internal/env/path_windows_test.go diff --git a/internal/env/env_win.go b/internal/env/env_win.go index 3564ed6d..71dc1d27 100644 --- a/internal/env/env_win.go +++ b/internal/env/env_win.go @@ -29,7 +29,8 @@ const PathVarName = "Path" func (p *Paths) String() string { - if os.Getenv(HookFlag) == "bash" { + shell := strings.ToLower(os.Getenv(HookFlag)) + if shell == "bash" || shell == "zsh" { pps := p.Slice() paths := make([]string, 0) for _, path := range pps { diff --git a/internal/env/path_windows_test.go b/internal/env/path_windows_test.go new file mode 100644 index 00000000..ea1833d0 --- /dev/null +++ b/internal/env/path_windows_test.go @@ -0,0 +1,85 @@ +//go:build windows + +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package env + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestWindowsPathFormatByShell(t *testing.T) { + for _, shell := range []string{"bash", "zsh", "pwsh", "clink", "nushell", ""} { + t.Run(shell, func(t *testing.T) { + t.Setenv(HookFlag, shell) + paths := NewPaths(EmptyPaths) + paths.Add(`C:\Program Files\Git\bin`) + paths.Add(`D:\中文 目录\bin`) + want := `C:\Program Files\Git\bin;D:\中文 目录\bin` + if shell == "bash" || shell == "zsh" { + want = "/c/Program Files/Git/bin:/d/中文 目录/bin" + } + if got := paths.String(); got != want { + t.Fatalf("PATH = %q, want %q", got, want) + } + }) + } +} + +// Run on Windows with MSYS2 zsh on PATH and VFOX_TEST_MSYS_ZSH=1. +func TestMSYSZshPATH(t *testing.T) { + if os.Getenv("VFOX_TEST_MSYS_ZSH") != "1" { + t.Skip("requires MSYS2 zsh") + } + zsh, err := exec.LookPath("zsh") + if err != nil { + t.Fatal(err) + } + t.Setenv(HookFlag, "zsh") + toolDir := filepath.Join(t.TempDir(), "中文 tools") + if err := os.Mkdir(toolDir, 0755); err != nil { + t.Fatal(err) + } + // A native executable checks that both Unicode and spaces survive PATH export. + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(exe) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(toolDir, "vfox-path-probe.exe"), data, 0755); err != nil { + t.Fatal(err) + } + paths := NewPaths(EmptyPaths) + paths.Add(toolDir) + paths.Merge(NewPaths(OsPaths)) + cmd := exec.Command(zsh, "-fc", `export PATH="$VFOX_TEST_PATH"; command -v ls && command -v vfox-path-probe`) + cmd.Env = append(os.Environ(), "VFOX_TEST_PATH="+paths.String()) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("zsh PATH lookup: %v\n%s", err, out) + } + if !strings.Contains(string(out), "vfox-path-probe") { + t.Fatalf("tool absent from zsh PATH: %s", out) + } +} From 7bc4b2f37784709783b270556284639a996c7fe4 Mon Sep 17 00:00:00 2001 From: aooohan Date: Sat, 12 Sep 2026 15:43:37 +0800 Subject: [PATCH 3/6] fix(cd): skip same-named executable shims when finding the shell Fixes #238 --- internal/shell/open_test.go | 93 +++++++++++++++++++++++++++++++++++++ internal/shell/shell.go | 34 ++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 internal/shell/open_test.go diff --git a/internal/shell/open_test.go b/internal/shell/open_test.go new file mode 100644 index 00000000..811f41f4 --- /dev/null +++ b/internal/shell/open_test.go @@ -0,0 +1,93 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package shell + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// The child processes model a shell -> vfox shim -> vfox process chain. +// Reopening the shim prints its executable name, so the test can distinguish +// it from the actual shell without launching an interactive shell. +func TestMain(m *testing.M) { + mode := os.Getenv("VFOX_SHELL_PROCESS_TEST") + if mode == "" { + os.Exit(m.Run()) + } + if len(os.Args) == 1 { + fmt.Println(filepath.Base(os.Args[0])) + os.Exit(0) + } + if mode == "child" { + if err := Open(os.Getppid()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) + } + next := "child" + if mode == "shell" { + next = "wrapper" + } + command := exec.Command(os.Getenv("VFOX_SHELL_TEST_BINARY"), "helper") + command.Env = append(os.Environ(), "VFOX_SHELL_PROCESS_TEST="+next) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + os.Exit(0) +} + +func TestOpenSkipsVfoxShim(t *testing.T) { + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + shellName := "fixture-shell" + filepath.Ext(executable) + shellDir := filepath.Join(t.TempDir(), "shell install 中文") + if err := os.Mkdir(shellDir, 0755); err != nil { + t.Fatal(err) + } + shellPath := filepath.Join(shellDir, shellName) + data, err := os.ReadFile(executable) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(shellPath, data, 0755); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"direct", "shell"} { + t.Run(mode, func(t *testing.T) { + command := exec.Command(shellPath, "helper") + command.Env = append(os.Environ(), "VFOX_SHELL_PROCESS_TEST="+mode, "VFOX_SHELL_TEST_BINARY="+executable) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("open shell: %v\n%s", err, output) + } + if got := strings.TrimSpace(string(output)); got != shellName { + t.Fatalf("opened %q, want the parent shell %q instead of the vfox shim", got, shellName) + } + }) + } +} diff --git a/internal/shell/shell.go b/internal/shell/shell.go index 001aae8b..7dbd007d 100644 --- a/internal/shell/shell.go +++ b/internal/shell/shell.go @@ -20,9 +20,11 @@ import ( "fmt" "os" "os/exec" + "path/filepath" "strings" "github.com/shirou/gopsutil/v4/process" + "github.com/version-fox/vfox/internal/env" "github.com/version-fox/vfox/internal/shared/logger" ) @@ -68,6 +70,33 @@ func Open(pid int) error { return fmt.Errorf("open a new shell failed, err:%w", err) } + self, err := os.Executable() + if err != nil { + return fmt.Errorf("open a new shell failed, err:%w", err) + } + // Scoop launches vfox through a same-named executable shim. Follow the + // native parent chain past those launchers, without assuming a shell name + // or treating an inherited/MSYS hook PID as a native process ID. + for { + executable, err := p.Exe() + if err != nil { + // Command-line lookup below remains usable on platforms where + // querying the process executable is unavailable. + break + } + if !strings.EqualFold(filepath.Base(executable), filepath.Base(self)) { + break + } + parent, err := p.Parent() + if err != nil { + return fmt.Errorf("find shell above vfox launcher %d: %w", p.Pid, err) + } + if parent.Pid <= 0 || parent.Pid == p.Pid { + return fmt.Errorf("cannot find shell above vfox launcher %d", p.Pid) + } + p = parent + } + cmdSlice, err := p.CmdlineSlice() if err != nil { return fmt.Errorf("open a new shell failed, err:%w", err) @@ -86,6 +115,11 @@ func Open(pid int) error { // Remove leading '-' from shell name (login shell indicator) shellName := cmdSlice[0] + if executable, err := p.Exe(); err == nil && executable != "" { + // The native executable path also avoids ambiguous command-line + // quoting when the shell is installed under a path with spaces. + shellName = executable + } if strings.HasPrefix(shellName, "-") { shellName = shellName[1:] } From 4f2d5daf13cf4313b84692526a4691e7f3c18ed4 Mon Sep 17 00:00:00 2001 From: aooohan Date: Sat, 12 Sep 2026 15:43:37 +0800 Subject: [PATCH 4/6] ci: exercise Windows shell and interrupted install regressions --- .github/workflows/ci.yml | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e57706c0..b095e767 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: VersionFox CI -on: [push] +on: [push, workflow_dispatch] concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -31,3 +31,31 @@ jobs: go test ./... -coverprofile=coverage.out -covermode=atomic - name: Upload coverage to Codecov uses: codecov/codecov-action@v7 + + windows-regressions: + name: Windows shell and interrupted installation regressions + runs-on: windows-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v4 + with: + go-version-file: go.mod + - uses: msys2/setup-msys2@v2 + id: msys2 + with: + msystem: MSYS + install: zsh + - name: Exercise native Windows processes and MSYS2 zsh + shell: pwsh + env: + MSYS2_LOCATION: ${{ steps.msys2.outputs.msys2-location }} + VFOX_TEST_MSYS_ZSH: '1' + run: | + $env:PATH = "$env:MSYS2_LOCATION\usr\bin;$env:PATH" + go test ./internal/env -run 'TestWindowsPathFormatByShell|TestMSYSZshPATH' -count=1 -v + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go test ./internal/shell -run TestOpenSkipsVfoxShim -count=1 -v + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + go test ./internal/sdk -run '^TestInstall' -count=1 -v + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } From 353106da750e77654aa63d352a4dac5fe93df620 Mon Sep 17 00:00:00 2001 From: aooohan Date: Sat, 12 Sep 2026 15:51:34 +0800 Subject: [PATCH 5/6] feat(completion): support Fish commands and options Install Fish completions through release packages and preserve shell-specific completion formatting. Fixes #22 --- .github/workflows/ci.yml | 23 +++- .goreleaser.yaml | 5 + README.md | 11 +- README_CN.md | 10 +- cmd/cmd.go | 10 +- cmd/completion.go | 35 ++++++ cmd/completion_test.go | 205 ++++++++++++++++++++++++++++++++-- completions/bash_autocomplete | 2 +- completions/vfox.fish | 22 ++++ completions/zsh_autocomplete | 4 +- 10 files changed, 307 insertions(+), 20 deletions(-) create mode 100644 cmd/completion.go create mode 100644 completions/vfox.fish diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b095e767..81eb5956 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v7 - name: Setup Go ${{ matrix.go-version }} - uses: actions/setup-go@v4 + uses: actions/setup-go@v7 with: go-version: ${{ matrix.go-version }} - name: Install dependencies @@ -38,7 +38,7 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@v7 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v7 with: go-version-file: go.mod - uses: msys2/setup-msys2@v2 @@ -59,3 +59,22 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } go test ./internal/sdk -run '^TestInstall' -count=1 -v if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + fish-completion: + name: Fish completion integration + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - name: Install Fish + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y fish + - name: Exercise native Fish completions + run: | + command -v fish + fish --version + go test ./cmd -run '^TestFishCompletion$' -count=1 -v diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 7140da51..c0f85875 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -128,6 +128,7 @@ brews: extra_install: |- bash_completion.install "completions/bash_autocomplete" => "vfox" zsh_completion.install "completions/zsh_autocomplete" => "_vfox" + fish_completion.install "completions/vfox.fish" nfpms: - file_name_template: >- @@ -161,6 +162,10 @@ nfpms: dst: /usr/share/zsh/site-functions/_vfox file_info: mode: 0644 + - src: ./completions/vfox.fish + dst: /usr/share/fish/vendor_completions.d/vfox.fish + file_info: + mode: 0644 - src: ./LICENSE dst: /usr/share/doc/vfox/copyright file_info: diff --git a/README.md b/README.md index 386760a7..95b19e3b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ and switch between different environment you need via the command line. - simple **plugin system** to add support for your runtime of choice - **automatically switches** runtime versions as you traverse your project - support for existing config files `.node-version`, `.nvmrc`, `.sdkmanrc` for easy migration -- shell completion available for common shells (Bash, ZSH, Powershell, Clink) +- shell completion available for common shells (Bash, ZSH, Fish, Powershell, Clink) ## Demo @@ -62,6 +62,15 @@ vfox activate nushell $nu.default-config-dir | save --append $nu.config-path > Remember to restart your shell to apply the changes. +Fish completion suggests commands and options. Homebrew, DEB, and RPM packages +install it automatically. For archive or source installations, run the following +from the extracted archive or repository directory: + +```fish +mkdir -p ~/.config/fish/completions +cp completions/vfox.fish ~/.config/fish/completions/vfox.fish +``` + #### 3. Add an SDK plugin ```bash diff --git a/README_CN.md b/README_CN.md index 4c12ddf6..ea182e22 100644 --- a/README_CN.md +++ b/README_CN.md @@ -23,7 +23,7 @@ - 简单的 **插件系统** 来添加对你选择的语言的支持 - 在您切换项目时, 帮您**自动切换**运行时版本 - 支持现有配置文件 `.node-version`、`.nvmrc`、`.sdkmanrc`,以方便迁移 -- 支持常用Shell(Powershell、Bash、ZSH),并提供补全功能 +- 支持常用 Shell(Powershell、Bash、ZSH、Fish、Clink),并提供补全功能 ## 演示 @@ -57,6 +57,14 @@ vfox activate nushell $nu.default-config-dir | save --append $nu.config-path > 请记住重启你的 Shell 以应用更改。 +Fish 补全支持命令和选项。Homebrew、DEB 和 RPM 软件包会自动安装补全脚本。 +如果通过压缩包或源码安装,请在解压后的目录或仓库目录中运行: + +```fish +mkdir -p ~/.config/fish/completions +cp completions/vfox.fish ~/.config/fish/completions/vfox.fish +``` + #### 3.添加插件 ```bash $ vfox add nodejs diff --git a/cmd/cmd.go b/cmd/cmd.go index 792e812b..627969c4 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -22,6 +22,7 @@ import ( "os" "github.com/urfave/cli/v3" + "github.com/version-fox/vfox/cmd/commands" "github.com/version-fox/vfox/internal" "github.com/version-fox/vfox/internal/shared/logger" @@ -64,11 +65,7 @@ func newCmd() *cmd { app.Version = version app.Description = "vfox is a cross-platform version manager, extendable via plugins. It allows you to quickly install and switch between different environment you need via the command line." app.Suggest = true - app.ShellComplete = func(ctx context.Context, cmd *cli.Command) { - for _, command := range cmd.Commands { - _, _ = fmt.Fprintln(cmd.Writer, command.Name) - } - } + app.ShellComplete = completeCommand debugFlags := &cli.BoolFlag{ Name: "debug", @@ -102,6 +99,9 @@ func newCmd() *cmd { commands.Exec, commands.Cd, } + for _, command := range app.Commands { + command.ShellComplete = completeCommand + } return &cmd{app: app, version: version} } diff --git a/cmd/completion.go b/cmd/completion.go new file mode 100644 index 00000000..82a3967a --- /dev/null +++ b/cmd/completion.go @@ -0,0 +1,35 @@ +/* + * Copyright 2026 Han Li and contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "context" + + "github.com/urfave/cli/v3" +) + +func completeCommand(ctx context.Context, command *cli.Command) { + // urfave/cli's subcommand completer reads parsed positional arguments, + // which can omit partial flags or truncate at "-". A parentless view uses + // the original os.Args instead, including the prefix being completed. + completion := &cli.Command{ + Flags: command.Flags, + Commands: command.Commands, + Writer: command.Root().Writer, + } + cli.DefaultCompleteWithFlags(ctx, completion) +} diff --git a/cmd/completion_test.go b/cmd/completion_test.go index 1b48c55e..08daa63b 100644 --- a/cmd/completion_test.go +++ b/cmd/completion_test.go @@ -20,9 +20,13 @@ import ( "bytes" "context" "os" + "os/exec" "path/filepath" + "runtime" + "slices" "strings" "testing" + "time" ) func TestCompletionScriptsUseCurrentCliFlag(t *testing.T) { @@ -32,6 +36,7 @@ func TestCompletionScriptsUseCurrentCliFlag(t *testing.T) { filepath.Join("..", "completions", "bash_autocomplete"), filepath.Join("..", "completions", "zsh_autocomplete"), filepath.Join("..", "completions", "powershell_autocomplete.ps1"), + filepath.Join("..", "completions", "vfox.fish"), } for _, scriptPath := range scriptPaths { @@ -59,17 +64,18 @@ func TestCompletionScriptsUseCurrentCliFlag(t *testing.T) { func TestGenerateShellCompletionFlagProducesSuggestions(t *testing.T) { t.Parallel() - command := newCmd() - output := &bytes.Buffer{} - command.app.Writer = output - command.app.ErrWriter = output - - err := command.app.Run(context.Background(), []string{"vfox", "--generate-shell-completion"}) + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + command := exec.Command(executable, "-test.run=^TestShellCompletionProcess$", "--", "--generate-shell-completion") + command.Env = append(os.Environ(), "VFOX_COMPLETION_TEST_PROCESS=1", "SHELL=fish") + output, err := command.CombinedOutput() if err != nil { - t.Fatalf("run shell completion: %v", err) + t.Fatalf("run shell completion: %v\n%s", err, output) } - suggestions := output.String() + suggestions := string(output) if !strings.Contains(suggestions, "install") { t.Fatalf("shell completion output missing install command: %q", suggestions) } @@ -77,3 +83,186 @@ func TestGenerateShellCompletionFlagProducesSuggestions(t *testing.T) { t.Fatalf("shell completion output missing use command: %q", suggestions) } } + +// TestShellCompletionProcess runs the real CLI in a separate process so that +// urfave/cli sees the same os.Args as it would when invoked by a shell. +func TestShellCompletionProcess(t *testing.T) { + if os.Getenv("VFOX_COMPLETION_TEST_PROCESS") != "1" { + return + } + separator := slices.Index(os.Args, "--") + if separator < 0 { + os.Exit(2) + } + os.Args = append([]string{"vfox"}, os.Args[separator+1:]...) + Execute(os.Args) + os.Exit(0) +} + +func TestFishCompletion(t *testing.T) { + script, err := filepath.Abs(filepath.Join("..", "completions", "vfox.fish")) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(script); err != nil { + t.Fatal(err) + } + fish, err := exec.LookPath("fish") + if err != nil { + t.Skip("fish is not installed; install fish to run native completion tests") + } + dir := installCompletionTestCLI(t) + // Fish can be started from Zsh: completion output must not inherit its + // colon-separated description format. + t.Setenv("SHELL", "/bin/zsh") + + for _, tt := range []struct { + name string + line string + want string + absent []string + noCalls bool + }{ + {name: "commands", line: "vfox ", want: "install", absent: []string{"activate", "completion"}}, + {name: "partial command", line: "vfox unins", want: "uninstall"}, + {name: "global flag", line: "vfox --de", want: "--debug"}, + {name: "double dash flag prefix", line: "vfox --", want: "--help"}, + {name: "install flag", line: "vfox install --y", want: "--yes"}, + {name: "command alias", line: "vfox i --a", want: "--all"}, + {name: "scope flag", line: "vfox use --g", want: "--global"}, + {name: "global option before command", line: "vfox --debug use --s", want: "--session"}, + {name: "flag after SDK argument", line: "vfox use nodejs --p", want: "--project"}, + {name: "quoted argument", line: "vfox info 'sdk with spaces' --f", want: "--format"}, + {name: "after separator", line: "vfox exec nodejs -- echo --", noCalls: true}, + } { + t.Run(tt.name, func(t *testing.T) { + trace := filepath.Join(t.TempDir(), "calls") + t.Setenv("VFOX_COMPLETION_TEST_TRACE", trace) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + command := exec.CommandContext(ctx, fish, "--no-config", "-c", `source $argv[1]; complete --do-complete "$argv[2]"`, "--", script, tt.line) + command.Dir = dir + var stdout, stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + t.Fatalf("fish completion: %v\n%s", err, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("fish completion emitted errors: %s", stderr.String()) + } + var suggestions []string + for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") { + candidate, _, _ := strings.Cut(line, "\t") + suggestions = append(suggestions, candidate) + } + if tt.want != "" && !slices.Contains(suggestions, tt.want) { + t.Errorf("completion for %q = %q, want %q", tt.line, suggestions, tt.want) + } + for _, candidate := range tt.absent { + if slices.Contains(suggestions, candidate) { + t.Errorf("completion for %q includes hidden command %q", tt.line, candidate) + } + } + if tt.noCalls { + if _, err := os.Stat(trace); !os.IsNotExist(err) { + t.Errorf("completion after -- invoked vfox: %v", err) + } + } + }) + } +} + +func installCompletionTestCLI(t *testing.T) string { + t.Helper() + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + bin := filepath.Join(dir, "bin") + if err := os.Mkdir(bin, 0755); err != nil { + t.Fatal(err) + } + // The wrapper only dispatches the test executable; it never evaluates the + // text being completed as shell code. + wrapper := "#!/bin/sh\nprintf '%s\\n' called >> \"$VFOX_COMPLETION_TEST_TRACE\"\nexec \"$VFOX_COMPLETION_TEST_EXECUTABLE\" -test.run='^TestShellCompletionProcess$' -- \"$@\"\n" + if err := os.WriteFile(filepath.Join(bin, "vfox"), []byte(wrapper), 0755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("HOME", dir) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(dir, "config")) + t.Setenv("VFOX_HOME", filepath.Join(dir, "vfox")) + t.Setenv("VFOX_COMPLETION_TEST_PROCESS", "1") + t.Setenv("VFOX_COMPLETION_TEST_EXECUTABLE", executable) + t.Setenv("VFOX_COMPLETION_TEST_TRACE", filepath.Join(dir, "calls")) + return dir +} + +func TestBashZshCompletionShellFormat(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX completion adapters are tested with native Bash and Zsh on Unix") + } + for _, shell := range []struct { + name string + parent string + program string + command string + flag string + }{ + { + name: "bash", parent: "/bin/zsh", command: "install", flag: "--global", + program: `source "$1" +shift +_init_completion() { words=("${COMP_WORDS[@]}"); cword=$COMP_CWORD; cur=${COMP_WORDS[COMP_CWORD]}; } +COMP_WORDS=(vfox "$@") +COMP_CWORD=$((${#COMP_WORDS[@]} - 1)) +__vfox_bash_autocomplete +printf '%s\n' "${COMPREPLY[@]}"`, + }, + { + name: "zsh", parent: "/bin/bash", command: "install:Install a version of the target SDK", flag: "--global:Used with the global environment", + program: `compdef() { :; } +_describe() { printf '%s\n' "${opts[@]}"; } +_files() { :; } +source "$1" +shift +words=(vfox "$@") +_vfox`, + }, + } { + t.Run(shell.name, func(t *testing.T) { + binary, err := exec.LookPath(shell.name) + if err != nil { + t.Skipf("%s is not installed", shell.name) + } + script, err := filepath.Abs(filepath.Join("..", "completions", shell.name+"_autocomplete")) + if err != nil { + t.Fatal(err) + } + dir := installCompletionTestCLI(t) + t.Setenv("SHELL", shell.parent) + for _, tt := range []struct { + args []string + want string + }{ + {args: []string{""}, want: shell.command}, + {args: []string{"use", "--g"}, want: shell.flag}, + } { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + args := append([]string{"-f", "-c", shell.program, "completion-test", script}, tt.args...) + command := exec.CommandContext(ctx, binary, args...) + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("%s completion: %v\n%s", shell.name, err, output) + } + if !slices.Contains(strings.Split(strings.TrimSpace(string(output)), "\n"), tt.want) { + t.Errorf("%s completion under SHELL=%s = %q, want %q", shell.name, shell.parent, output, tt.want) + } + } + }) + } +} diff --git a/completions/bash_autocomplete b/completions/bash_autocomplete index 6d301dc4..f330bbbb 100644 --- a/completions/bash_autocomplete +++ b/completions/bash_autocomplete @@ -25,7 +25,7 @@ __vfox_bash_autocomplete() { else requestComp="${words[*]} --generate-shell-completion" fi - opts=$(eval "${requestComp}" 2>/dev/null) + opts=$(SHELL=bash eval "${requestComp}" 2>/dev/null) COMPREPLY=($(compgen -W "${opts}" -- ${cur})) return 0 fi diff --git a/completions/vfox.fish b/completions/vfox.fish new file mode 100644 index 00000000..75a9a193 --- /dev/null +++ b/completions/vfox.fish @@ -0,0 +1,22 @@ +# Fish completion uses the same command definitions as the vfox CLI. +function __vfox_fish_complete + set -l args (commandline -opc) + set -l current (commandline -ct) + + # The CLI disables completion after -- and would execute the command. + if contains -- -- $args + return + end + + if string match -q -- '-*' "$current" + # Ask for all flags and let Fish filter the prefix. + # Never pass a literal --, which would execute the command instead. + set -a args - + end + + # A parent Zsh may have left SHELL set to zsh. Request plain candidates. + set -lx SHELL fish + command $args --generate-shell-completion 2>/dev/null +end + +complete -c vfox -f -a '(__vfox_fish_complete)' diff --git a/completions/zsh_autocomplete b/completions/zsh_autocomplete index c836c900..a33e2de4 100644 --- a/completions/zsh_autocomplete +++ b/completions/zsh_autocomplete @@ -8,9 +8,9 @@ _vfox() { local current current=${words[-1]} if [[ "$current" == "-"* ]]; then - opts=("${(@f)$(${words[@]:0:#words[@]-1} ${current} --generate-shell-completion)}") + opts=("${(@f)$(SHELL=zsh ${words[@]:0:#words[@]-1} ${current} --generate-shell-completion)}") else - opts=("${(@f)$(${words[@]:0:#words[@]-1} --generate-shell-completion)}") + opts=("${(@f)$(SHELL=zsh ${words[@]:0:#words[@]-1} --generate-shell-completion)}") fi if [[ "${opts[1]}" != "" ]]; then From 563ba7bc9d449763830bac04940ab0ae46d097c5 Mon Sep 17 00:00:00 2001 From: aooohan Date: Sat, 12 Sep 2026 15:53:44 +0800 Subject: [PATCH 6/6] ci: use the Go version declared by the module --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81eb5956..557b353a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,14 +12,13 @@ jobs: strategy: matrix: os: [macos-latest, ubuntu-latest, windows-latest] - go-version: [ '1.21.x' ] steps: - uses: actions/checkout@v7 - - name: Setup Go ${{ matrix.go-version }} + - name: Setup Go from go.mod uses: actions/setup-go@v7 with: - go-version: ${{ matrix.go-version }} + go-version-file: go.mod - name: Install dependencies run: | go get .