Skip to content

Commit 0a42eac

Browse files
committed
feat: support multi-sdk exec
1 parent 55fa882 commit 0a42eac

5 files changed

Lines changed: 170 additions & 69 deletions

File tree

cmd/commands/exec.go

Lines changed: 154 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"fmt"
2222
"os"
2323
"os/exec"
24+
"slices"
2425
"strings"
2526

2627
"github.com/urfave/cli/v3"
@@ -29,109 +30,199 @@ import (
2930
"github.com/version-fox/vfox/internal/sdk"
3031
)
3132

33+
type execSDKSpec struct {
34+
Name string
35+
Version sdk.Version
36+
}
37+
38+
const (
39+
execCommandName = "exec"
40+
execCommandAlias = "x"
41+
)
42+
3243
var Exec = &cli.Command{
33-
Name: "exec",
34-
Aliases: []string{"x"},
44+
Name: execCommandName,
45+
Aliases: []string{execCommandAlias},
3546
Usage: "Execute a command in vfox managed environment",
3647
Action: execCmd,
3748
}
3849

3950
func execCmd(ctx context.Context, cmd *cli.Command) error {
40-
args := cmd.Args()
41-
if args.Len() < 2 {
42-
return fmt.Errorf("usage: vfox exec <sdk>[@<version>] <command> [args...]\nExample: vfox exec node@20 -- node -v")
51+
sdkSpecs, command, cmdArgs, err := parseExecInvocation(cmd.Args().Slice(), os.Args)
52+
if err != nil {
53+
return err
54+
}
55+
return executeInVfoxEnv(sdkSpecs, command, cmdArgs)
56+
}
57+
58+
func parseExecInvocation(parsedArgs, rawArgs []string) ([]execSDKSpec, string, []string, error) {
59+
if len(parsedArgs) < 2 {
60+
return nil, "", nil, execUsageError()
4361
}
4462

45-
// 1. Parse sdk@version (first argument)
46-
firstArg := args.First()
47-
parts := strings.Split(firstArg, "@")
48-
sdkName := parts[0]
49-
var sdkVersion sdk.Version
50-
if len(parts) > 1 {
51-
sdkVersion = sdk.Version(strings.TrimPrefix(parts[1], "v"))
63+
rawExecArgs := rawExecArgs(rawArgs)
64+
if separatorIndex := slices.Index(rawExecArgs, "--"); separatorIndex >= 0 {
65+
sdkArgs := rawExecArgs[:separatorIndex]
66+
commandArgs := rawExecArgs[separatorIndex+1:]
67+
if len(sdkArgs) == 0 || len(commandArgs) == 0 {
68+
return nil, "", nil, execUsageError()
69+
}
70+
71+
sdkSpecs := make([]execSDKSpec, 0, len(sdkArgs))
72+
for _, sdkArg := range sdkArgs {
73+
spec, err := parseExecSDKSpec(sdkArg)
74+
if err != nil {
75+
return nil, "", nil, err
76+
}
77+
sdkSpecs = append(sdkSpecs, spec)
78+
}
79+
return sdkSpecs, commandArgs[0], commandArgs[1:], nil
5280
}
5381

54-
// 2. Second argument is the command, rest are command arguments
55-
command := args.Get(1)
56-
cmdArgs := args.Slice()[2:]
82+
if len(parsedArgs) > 2 && strings.Contains(parsedArgs[1], "@") {
83+
return nil, "", nil, fmt.Errorf("multiple SDKs require '--' before the command\n%s", execUsageLine())
84+
}
5785

58-
// 3. Execute the command
59-
return executeInVfoxEnv(sdkName, sdkVersion, command, cmdArgs)
86+
spec, err := parseExecSDKSpec(parsedArgs[0])
87+
if err != nil {
88+
return nil, "", nil, err
89+
}
90+
return []execSDKSpec{spec}, parsedArgs[1], parsedArgs[2:], nil
91+
}
92+
93+
func rawExecArgs(rawArgs []string) []string {
94+
for i := 1; i < len(rawArgs); i++ {
95+
if rawArgs[i] == execCommandName || rawArgs[i] == execCommandAlias {
96+
return rawArgs[i+1:]
97+
}
98+
}
99+
return nil
100+
}
101+
102+
func parseExecSDKSpec(arg string) (execSDKSpec, error) {
103+
arg = strings.TrimSpace(arg)
104+
if arg == "" {
105+
return execSDKSpec{}, execUsageError()
106+
}
107+
108+
parts := strings.SplitN(arg, "@", 2)
109+
spec := execSDKSpec{Name: parts[0]}
110+
if spec.Name == "" {
111+
return execSDKSpec{}, fmt.Errorf("invalid SDK spec %q", arg)
112+
}
113+
if len(parts) == 2 {
114+
spec.Version = sdk.Version(strings.TrimPrefix(parts[1], "v"))
115+
}
116+
return spec, nil
117+
}
118+
119+
func execUsageLine() string {
120+
return "usage: vfox exec <sdk>[@<version>]... -- <command> [args...]\nExample: vfox exec nodejs@24.14.0 golang@1.25.6 -- npm install -g pnpm"
121+
}
122+
123+
func execUsageError() error {
124+
return fmt.Errorf("%s", execUsageLine())
60125
}
61126

62127
// executeInVfoxEnv executes a command in vfox managed environment
63-
func executeInVfoxEnv(sdkName string, sdkVersion sdk.Version, command string, cmdArgs []string) error {
128+
func executeInVfoxEnv(sdkSpecs []execSDKSpec, command string, cmdArgs []string) error {
64129
manager, err := internal.NewSdkManager()
65130
if err != nil {
66131
return fmt.Errorf("failed to create sdk manager: %w", err)
67132
}
68133
defer manager.Close()
69134

70-
// Lookup SDK
71-
sdkSource, err := manager.LookupSdk(sdkName)
72-
if err != nil {
73-
return fmt.Errorf("%s not supported, error: %w", sdkName, err)
74-
}
75-
76-
// If version is not specified, try to get it from current scope
77-
var resolvedVersion sdk.Version
78-
if sdkVersion == "" {
79-
// Get version from scope chain: Global > Session > Project
80-
chain, err := manager.RuntimeEnvContext.LoadVfoxTomlChainByScopes(
81-
env.Global, env.Session, env.Project,
82-
)
135+
sdkEnvs := make([]*env.Envs, 0, len(sdkSpecs))
136+
for _, sdkSpec := range sdkSpecs {
137+
specEnvs, err := resolveExecSDKEnv(manager, sdkSpec)
83138
if err != nil {
84-
return fmt.Errorf("failed to load config: %w", err)
139+
return err
85140
}
141+
sdkEnvs = append(sdkEnvs, specEnvs)
142+
}
86143

87-
version, _, ok := chain.GetToolVersion(sdkName)
88-
if !ok || version == "" {
89-
return fmt.Errorf("no version configured for %s. Please use 'vfox use' to set a version first", sdkName)
144+
mergedEnvs := mergeExecEnvsByPriority(sdkEnvs)
145+
applyExecSystemPaths(manager.RuntimeEnvContext, mergedEnvs)
146+
147+
envMap := make(map[string]string, len(mergedEnvs.Variables)+1)
148+
for key, value := range mergedEnvs.Variables {
149+
if value != nil {
150+
envMap[key] = *value
90151
}
91-
resolvedVersion = sdk.Version(version)
92-
} else {
93-
// Use the user-specified version
94-
resolvedVersion = sdkVersion
95-
96-
// Check if installed, auto-install if not
97-
if !sdkSource.CheckRuntimeExist(resolvedVersion) {
98-
fmt.Printf("SDK %s@%s not found, installing...\n", sdkName, resolvedVersion)
99-
if err := sdkSource.Install(resolvedVersion); err != nil {
100-
return fmt.Errorf("failed to install %s@%s: %w", sdkName, resolvedVersion, err)
101-
}
152+
}
153+
envMap["PATH"] = mergedEnvs.Paths.String()
154+
return executeCommand(command, cmdArgs, envMap)
155+
}
156+
157+
func resolveExecSDKEnv(manager *internal.Manager, sdkSpec execSDKSpec) (*env.Envs, error) {
158+
sdkSource, err := manager.LookupSdk(sdkSpec.Name)
159+
if err != nil {
160+
return nil, fmt.Errorf("%s not supported, error: %w", sdkSpec.Name, err)
161+
}
162+
163+
resolvedVersion, err := resolveExecSDKVersion(manager, sdkSpec)
164+
if err != nil {
165+
return nil, err
166+
}
167+
if sdkSpec.Version != "" && !sdkSource.CheckRuntimeExist(resolvedVersion) {
168+
fmt.Printf("SDK %s@%s not found, installing...\n", sdkSpec.Name, resolvedVersion)
169+
if err := sdkSource.Install(resolvedVersion); err != nil {
170+
return nil, fmt.Errorf("failed to install %s@%s: %w", sdkSpec.Name, resolvedVersion, err)
102171
}
103172
}
104173

105-
// Get environment variables
106-
// Note: Using EnvKeys to get the runtime package paths
107174
runtimePackage, err := sdkSource.GetRuntimePackage(resolvedVersion)
108175
if err != nil {
109-
return fmt.Errorf("failed to get runtime package for %s@%s: %w", sdkName, resolvedVersion, err)
176+
return nil, fmt.Errorf("failed to get runtime package for %s@%s: %w", sdkSpec.Name, resolvedVersion, err)
110177
}
178+
111179
envKeys, err := sdkSource.EnvKeys(runtimePackage)
112180
if err != nil {
113-
return fmt.Errorf("failed to get env keys for %s@%s: %w", sdkName, resolvedVersion, err)
181+
return nil, fmt.Errorf("failed to get env keys for %s@%s: %w", sdkSpec.Name, resolvedVersion, err)
114182
}
183+
return envKeys, nil
184+
}
115185

116-
// Build environment variable map
117-
envMap := make(map[string]string)
186+
func resolveExecSDKVersion(manager *internal.Manager, sdkSpec execSDKSpec) (sdk.Version, error) {
187+
if sdkSpec.Version != "" {
188+
return sdkSpec.Version, nil
189+
}
118190

119-
// Add PATH from envKeys.Paths
120-
if envKeys.Paths != nil && envKeys.Paths.Slice() != nil {
121-
paths := envKeys.Paths.Slice()
122-
pathStr := strings.Join(paths, string(os.PathListSeparator))
123-
envMap["PATH"] = pathStr
191+
chain, err := manager.RuntimeEnvContext.LoadVfoxTomlChainByScopes(env.Global, env.Session, env.Project)
192+
if err != nil {
193+
return "", fmt.Errorf("failed to load config: %w", err)
124194
}
125195

126-
// Add other variables from envKeys.Variables
127-
for key, value := range envKeys.Variables {
128-
if value != nil {
129-
envMap[key] = *value
196+
version, _, ok := chain.GetToolVersion(sdkSpec.Name)
197+
if !ok || version == "" {
198+
return "", fmt.Errorf("no version configured for %s. Please use 'vfox use' to set a version first", sdkSpec.Name)
199+
}
200+
return sdk.Version(version), nil
201+
}
202+
203+
func mergeExecEnvsByPriority(envsByPriority []*env.Envs) *env.Envs {
204+
merged := env.NewEnvs()
205+
for _, sdkEnvs := range envsByPriority {
206+
if sdkEnvs == nil {
207+
continue
208+
}
209+
merged.Paths.Merge(sdkEnvs.Paths)
210+
}
211+
for i := len(envsByPriority) - 1; i >= 0; i-- {
212+
if sdkEnvs := envsByPriority[i]; sdkEnvs != nil {
213+
merged.Variables.Merge(sdkEnvs.Variables)
130214
}
131215
}
216+
return merged
217+
}
132218

133-
// Execute command
134-
return executeCommand(command, cmdArgs, envMap)
219+
func applyExecSystemPaths(runtimeEnvContext *env.RuntimeEnvContext, sdkEnvs *env.Envs) {
220+
if sdkEnvs == nil {
221+
return
222+
}
223+
prefixPaths, cleanSystemPaths := runtimeEnvContext.SplitSystemPaths()
224+
sdkEnvs.Paths.Merge(prefixPaths)
225+
sdkEnvs.Paths.Merge(cleanSystemPaths)
135226
}
136227

137228
// executeCommand executes a command in the specified environment

docs/usage/all-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ vfox install <sdk-name>@<version> Install the specified version of SDK
1212
vfox uninstall <sdk-name>@<version> Uninstall the specified version of SDK
1313
vfox use [--global --project --session] <sdk-name>[@<version>] Use the specified version of SDK for different scope
1414
vfox unuse [--global --project --session] <sdk-name> Unset the version of SDK from specified scope
15-
vfox exec <sdk-name>[@<version>] <command> [args...] Execute a command in vfox managed environment
15+
vfox exec <sdk-name>[@<version>]... -- <command> [args...] Execute a command in vfox managed environment
1616
vfox list [<sdk-name>] List all installed versions of SDK
1717
vfox current [<sdk-name>] Show the current version of SDK
1818
vfox config [<key>] [<value>] Setup, view config

docs/usage/core-commands.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,15 +203,17 @@ Execute a command in a vfox managed environment.
203203
**Usage**
204204

205205
```shell
206-
vfox exec <sdk-name>[@<version>] -- <command> [args...]
206+
vfox exec <sdk-name>[@<version>]... -- <command> [args...]
207207

208-
vfox x <sdk-name>[@<version>] -- <command> [args...]
208+
vfox x <sdk-name>[@<version>]... -- <command> [args...]
209209
```
210210

211211
`sdk-name`: SDK name
212212

213213
`version`[optional]: Specify the version to use. If not provided, uses the version configured in the current scope.
214214

215+
You can specify multiple SDKs before `--`. They are merged from left to right, and the leftmost SDK has higher priority when PATH or environment variables overlap.
216+
215217
`command`: The command to execute
216218

217219
`args`: Arguments to pass to the command
@@ -243,6 +245,9 @@ vfox exec nodejs@24.14.0 -- bash -lc 'node -v && npm -v'
243245
# Execute command with specified version
244246
vfox exec nodejs@20.9.0 -- node -v
245247

248+
# Execute command with multiple SDKs
249+
vfox exec nodejs@24.14.0 golang@1.25.6 -- npm install -g @qwen-code/qwen-code@latest
250+
246251
# Run build in maven environment
247252
vfox exec maven@3.9.1 -- mvn clean install
248253

docs/zh-hans/usage/all-commands.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ vfox install <sdk-name>@<version> Install the specified version of SDK
1212
vfox uninstall <sdk-name>@<version> Uninstall the specified version of SDK
1313
vfox use [--global --project --session] <sdk-name>[@<version>] Use the specified version of SDK for different scope
1414
vfox unuse [--global --project --session] <sdk-name> Unset the SDK version from the specified scope
15-
vfox exec <sdk-name>[@<version>] <command> [args...] Execute a command in vfox managed environment
15+
vfox exec <sdk-name>[@<version>]... -- <command> [args...] Execute a command in vfox managed environment
1616
vfox list [<sdk-name>] List all installed versions of SDK
1717
vfox current [<sdk-name>] Show the current version of SDK
1818
vfox config [<key>] [<value>] Setup, view config

docs/zh-hans/usage/core-commands.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,15 +205,17 @@ vfox upgrade
205205
**用法**
206206

207207
```shell
208-
vfox exec <sdk-name>[@<version>] -- <command> [args...]
208+
vfox exec <sdk-name>[@<version>]... -- <command> [args...]
209209

210-
vfox x <sdk-name>[@<version>] -- <command> [args...]
210+
vfox x <sdk-name>[@<version>]... -- <command> [args...]
211211
```
212212

213213
`sdk-name`: SDK 名称
214214

215215
`version`[可选]: 指定使用的版本。如不传,则使用当前作用域配置的版本。
216216

217+
可以在 `--` 之前连续指定多个 SDK。它们会按从左到右的顺序合并;如果 PATH 或环境变量发生重叠,左边的 SDK 优先级更高。
218+
217219
`command`: 要执行的命令
218220

219221
`args`: 传递给命令的参数
@@ -246,6 +248,9 @@ vfox exec nodejs@24.14.0 -- bash -lc 'node -v && npm -v'
246248
# 使用指定版本执行命令
247249
vfox exec nodejs@20.9.0 -- node -v
248250

251+
# 同时指定多个 SDK 执行命令
252+
vfox exec nodejs@24.14.0 golang@1.25.6 -- npm install -g @qwen-code/qwen-code@latest
253+
249254
# 在 maven 环境中执行构建
250255
vfox exec maven@3.9.1 -- mvn clean install
251256

0 commit comments

Comments
 (0)