-
Notifications
You must be signed in to change notification settings - Fork 310
Expand file tree
/
Copy pathrun.go
More file actions
290 lines (260 loc) · 8.45 KB
/
run.go
File metadata and controls
290 lines (260 loc) · 8.45 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
// Copyright 2024 Jetify Inc. and contributors. All rights reserved.
// Use of this source code is governed by the license in the LICENSE file.
package boxcli
import (
"fmt"
"log/slog"
"os"
"slices"
"sort"
"strings"
"github.com/pkg/errors"
"github.com/samber/lo"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.jetify.com/devbox/internal/boxcli/multi"
"go.jetify.com/devbox/internal/boxcli/usererr"
"go.jetify.com/devbox/internal/devbox"
"go.jetify.com/devbox/internal/devbox/devopt"
"go.jetify.com/devbox/internal/redact"
"go.jetify.com/devbox/internal/ux"
)
type runCmdFlags struct {
envFlag
config configFlags
omitNixEnv bool
pure bool
listScripts bool
recomputeEnv bool
allProjects bool
}
// runFlagDefaults are the flag default values that differ
// from the `devbox` command versus `devbox global` command.
type runFlagDefaults struct {
omitNixEnv bool
}
func runCmd(defaults runFlagDefaults) *cobra.Command {
flags := runCmdFlags{}
command := &cobra.Command{
Use: "run [<script> | <cmd>]",
Short: "Run a script or command in a shell with access to your packages",
Long: "Start a new shell and runs your script or command in it, exiting when done.\n\n" +
"The script must be defined in `devbox.json`, or else it will be interpreted as an " +
"arbitrary command. You can pass arguments to your script or command. Everything " +
"after `--` will be passed verbatim into your command (see examples).\n\n",
Example: "\nRun a command directly:\n\n devbox add cowsay\n devbox run cowsay hello\n " +
"devbox run -- cowsay -d hello\n\nRun a script (defined as `\"moo\": \"cowsay moo\"`) " +
"in your devbox.json:\n\n devbox run moo",
PreRunE: ensureNixInstalled,
RunE: func(cmd *cobra.Command, args []string) error {
return runScriptCmd(cmd, args, flags)
},
}
flags.envFlag.register(command)
flags.config.register(command)
command.Flags().BoolVar(
&flags.pure, "pure", false, "if this flag is specified, devbox runs the script in an isolated environment inheriting almost no variables from the current environment. A few variables, in particular HOME, USER and DISPLAY, are retained.")
command.Flags().BoolVarP(
&flags.listScripts, "list", "l", false, "list all scripts defined in devbox.json")
command.Flags().BoolVar(
&flags.omitNixEnv, "omit-nix-env", defaults.omitNixEnv,
"shell environment will omit the env-vars from print-dev-env",
)
_ = command.Flags().MarkHidden("omit-nix-env")
command.Flags().BoolVar(&flags.recomputeEnv, "recompute", true, "recompute environment if needed")
command.Flags().BoolVar(
&flags.allProjects,
"all-projects",
false,
"run command in all projects in the working directory, recursively. If command is not found in any project, it will be skipped.",
)
command.ValidArgs = listScripts(command, flags)
return command
}
func listScripts(cmd *cobra.Command, flags runCmdFlags) []string {
path := flags.config.path
// Special code path for shell completion.
// Landau: I'm not entirely sure why:
// * Flags need to be parsed again
// * cmd.Flag("config") contains the correct value, but flags.config.path is empty
// Give my low confidence, I'm making this a very narrow code path.
if path == "" && slices.Contains(os.Args, "__complete") {
_ = cmd.ParseFlags(os.Args)
if flag := cmd.Flag("config"); flag != nil && flag.Value != nil {
path = flag.Value.String()
}
}
devboxOpts := &devopt.Opts{
Dir: path,
Environment: flags.config.environment,
Stderr: cmd.ErrOrStderr(),
IgnoreWarnings: true,
}
if flags.allProjects {
boxes, err := multi.Open(devboxOpts)
if err != nil {
slog.Error("failed to open devbox", "err", err)
return nil
}
scripts := []string{}
for _, box := range boxes {
scripts = append(scripts, box.ListScripts()...)
}
sort.Strings(scripts)
return lo.Uniq(scripts)
}
box, err := devbox.Open(devboxOpts)
if err != nil {
slog.Error("failed to open devbox", "err", err)
return nil
}
return box.ListScripts()
}
func runScriptCmd(cmd *cobra.Command, args []string, flags runCmdFlags) error {
ctx := cmd.Context()
if len(args) == 0 || flags.listScripts {
scripts := listScripts(cmd, flags)
if len(scripts) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "no scripts defined in devbox.json")
return nil
}
fmt.Fprintln(cmd.OutOrStdout(), "Available scripts:")
for _, p := range scripts {
fmt.Fprintf(cmd.OutOrStdout(), "* %s\n", p)
}
return nil
}
path, script, scriptArgs, err := parseScriptArgs(args, flags)
if err != nil {
return redact.Errorf("error parsing script arguments: %w", err)
}
slog.Debug("run script", "script", script, "args", scriptArgs)
env, err := flags.Env(path)
if err != nil {
return err
}
boxes := []*devbox.Devbox{}
devboxOpts := &devopt.Opts{
Dir: path,
Env: env,
Environment: flags.config.environment,
Stderr: cmd.ErrOrStderr(),
}
if flags.allProjects {
boxes, err = multi.Open(devboxOpts)
if err != nil {
return errors.WithStack(err)
}
} else {
box, err := devbox.Open(devboxOpts)
if err != nil {
return redact.Errorf("error reading devbox.json: %w", err)
}
boxes = append(boxes, box)
}
envOpts := devopt.EnvOptions{
Hooks: devopt.LifecycleHooks{
OnStaleState: func() {
if !flags.recomputeEnv {
ux.FHidableWarning(
ctx,
cmd.ErrOrStderr(),
devbox.StateOutOfDateMessage,
"with --recompute=true",
)
}
},
},
OmitNixEnv: flags.omitNixEnv,
Pure: flags.pure,
SkipRecompute: !flags.recomputeEnv,
}
if flags.allProjects {
boxes = lo.Filter(boxes, func(box *devbox.Devbox, _ int) bool {
return slices.Contains(box.ListScripts(), script)
})
}
for _, box := range boxes {
ux.Finfof(
cmd.ErrOrStderr(),
"Running script %q on %s\n",
script,
box.ProjectDir(),
)
if err := box.RunScript(ctx, envOpts, script, scriptArgs); err != nil {
return redact.Errorf("error running script %q in Devbox: %w", script, err)
}
}
return nil
}
func parseScriptArgs(args []string, flags runCmdFlags) (string, string, []string, error) {
if len(args) == 0 {
// this should never happen because cobra should prevent it, but it's better to be defensive.
return "", "", nil, usererr.New("no command or script provided")
}
script := args[0]
scriptArgs := args[1:]
return flags.config.path, script, scriptArgs, nil
}
func wrapArgsForRun(rootCmd *cobra.Command, args []string) []string {
// if the first argument is not "run", we don't need to do anything. If there
// are 2 or fewer arguments, we also don't need to do anything because there
// are no flags after a non-run non-flag arg.
// IMPROVEMENT: technically users can pass a flag before the subcommand "run"
if len(args) <= 2 || args[0] != "run" || slices.Contains(args, "--") {
return args
}
cmd, found := lo.Find(
rootCmd.Commands(),
func(item *cobra.Command) bool { return item.Name() == "run" },
)
if !found {
return args
}
_ = cmd.InheritedFlags() // bug in cobra requires this to be called to ensure flags contains inherited flags.
runFlags := cmd.Flags()
// typical args can be of the form:
// run --flag1 val1 -f val2 --flag3=val3 --bool-flag python --version
// We handle each different type of flag
// (flag with equals, long-form, short-form, and defaulted flags)
// Note that defaulted does not mean initial value, it only means flags
// that don't require a value.
// For example, --bool-flag has NoOptDefVal set to "true".
i := 1
for i < len(args) {
arg := args[i]
if !strings.HasPrefix(arg, "-") {
// We found and argument that is not part of the flags, so we can stop
// This inserts a "--" before the first non-flag argument
// Turning
// run --flag1 val1 command --flag2 val2
// into
// run --flag1 val1 -- command --flag2 val2
return append(args[:i+1], append([]string{"--"}, args[i+1:]...)...)
}
if strings.HasPrefix(arg, "-") && strings.Contains(arg, "=") {
// This is a flag with an equals sign, so we can skip it
i++
continue
}
var flag *pflag.Flag
if strings.HasPrefix(arg, "--") {
flag = runFlags.Lookup(strings.TrimLeft(arg, "-"))
} else {
flag = runFlags.ShorthandLookup(strings.TrimLeft(arg, "-"))
}
if flag == nil {
// found an invalid flag, just return args as-is
return args
}
if flag.NoOptDefVal == "" {
// This is a non-boolean flag, e.g. --flag1 val1
i += 2
} else {
// This is a boolean flag, e.g. --bool-flag
i++
}
}
// This means there is no non-flag command. Just return as is.
return args
}