Skip to content

Commit 799368c

Browse files
authored
[fish] make init hook recursion fish compatible (#1771)
## Summary Hopefully this fix all the fish compatibility issues that were introduced with the init hook recursion protection. The strategy in this PR is: * Wrap init hooks with POSIX and fish compatible wrapper. (no unset used, only export and dot source) * Wrap scripts in POSIX compatible wrapper (but not fish). * The script wrapper will only source init hooks once. The first time it sources it, the init hook wrapper sets an env var and proceeds to run the hook. If any command in the init hook is a devbox script, it will not call init hooks recursively because the env var is set. Once the init hook is done, it uses `export` to make the var empty. (`export` is compatible with POSIX and fish, `unset` is not) * The script wrapper is not fish compatible, but that's OK because scripts are always run with `sh`. (I think it may be easy to make the script fish compatible by using `test if we ever decide to support that). ## How was it tested? * added `devbox run` calls to init hook * Shelled in (with zsh and fish) * Ran scripts * Tested sourcing `devbox shellenv --init-hook` (with zsh and fish)
1 parent 67f4f9f commit 799368c

File tree

7 files changed

+75
-71
lines changed

7 files changed

+75
-71
lines changed

internal/devbox/devbox.go

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,11 @@ func (d *Devbox) RunScript(ctx context.Context, cmdName string, cmdArgs []string
258258
// which we don't want. So, one solution is to write the entire command and its arguments into the
259259
// file itself, but that may not be great if the variables contain sensitive information. Instead,
260260
// we save the entire command (with args) into the DEVBOX_RUN_CMD var, and then the script evals it.
261-
err := shellgen.WriteScriptFile(d, arbitraryCmdFilename, shellgen.ScriptBody(d, "eval $DEVBOX_RUN_CMD\n"))
261+
scriptBody, err := shellgen.ScriptBody(d, "eval $DEVBOX_RUN_CMD\n")
262+
if err != nil {
263+
return err
264+
}
265+
err = shellgen.WriteScriptFile(d, arbitraryCmdFilename, scriptBody)
262266
if err != nil {
263267
return err
264268
}
@@ -325,13 +329,7 @@ func (d *Devbox) EnvExports(ctx context.Context, opts devopt.EnvExportsOpts) (st
325329
envStr := exportify(envs)
326330

327331
if opts.RunHooks {
328-
hooksFilename := shellgen.HooksFilename
329-
if isFishShell() {
330-
hooksFilename = shellgen.HooksFishFilename
331-
}
332-
333-
hooksStr := ". " + shellgen.ScriptPath(d.ProjectDir(), hooksFilename)
334-
332+
hooksStr := ". " + shellgen.ScriptPath(d.ProjectDir(), shellgen.HooksFilename)
335333
envStr = fmt.Sprintf("%s\n%s;\n", envStr, hooksStr)
336334
}
337335

internal/devbox/shell.go

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -311,10 +311,8 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
311311
}()
312312

313313
tmpl := shellrcTmpl
314-
hooksFilePath := shellgen.ScriptPath(s.projectDir, shellgen.HooksFilename)
315314
if s.name == shFish {
316315
tmpl = fishrcTmpl
317-
hooksFilePath = shellgen.ScriptPath(s.projectDir, shellgen.HooksFishFilename)
318316
}
319317

320318
err = tmpl.Execute(shellrcf, struct {
@@ -333,7 +331,7 @@ func (s *DevboxShell) writeDevboxShellrc() (path string, err error) {
333331
ProjectDir: s.projectDir,
334332
OriginalInit: string(bytes.TrimSpace(userShellrc)),
335333
OriginalInitPath: s.userShellrcPath,
336-
HooksFilePath: hooksFilePath,
334+
HooksFilePath: shellgen.ScriptPath(s.projectDir, shellgen.HooksFilename),
337335
ShellStartTime: telemetry.FormatShellStart(s.shellStartTime),
338336
HistoryFile: strings.TrimSpace(s.historyFile),
339337
ExportEnv: exportify(s.env),

internal/shellgen/scripts.go

Lines changed: 44 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package shellgen
22

33
import (
4+
"bytes"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -18,20 +19,22 @@ import (
1819
"go.jetpack.io/devbox/internal/plugin"
1920
)
2021

21-
//go:embed tmpl/init-hook.tmpl
22-
var initHookTmpl string
22+
//go:embed tmpl/script-wrapper.tmpl
23+
var scriptWrapperTmplString string
24+
var scriptWrapperTmpl = template.Must(template.New("script-wrapper").Parse(scriptWrapperTmplString))
2325

24-
//go:embed tmpl/init-hook-fish.tmpl
25-
var initHookFishTmpl string
26+
//go:embed tmpl/init-hook-wrapper.tmpl
27+
var initHookWrapperString string
28+
var initHookWrapperTmpl = template.Must(template.New("init-hook-wrapper").Parse(initHookWrapperString))
2629

2730
const scriptsDir = ".devbox/gen/scripts"
2831

29-
// HooksFilename is the name of the file that contains the project's init-hooks and plugin hooks
30-
const HooksFilename = ".hooks"
31-
32-
// This is only used in shellrc_fish.tmpl. A bit of a hack, because scripts use
33-
// sh instead of fish.
34-
const HooksFishFilename = ".hooks.fish"
32+
// HooksFilename is the name of the file that contains a wrapper of the
33+
// project's init-hooks and plugin hooks
34+
const (
35+
HooksFilename = ".hooks"
36+
rawHooksFilename = ".raw-hooks"
37+
)
3538

3639
type devboxer interface {
3740
Config() *devconfig.Config
@@ -69,20 +72,25 @@ func WriteScriptsToFiles(devbox devboxer) error {
6972
}
7073
hooks := strings.Join(append(pluginHooks, devbox.Config().InitHook().String()), "\n\n")
7174
// always write it, even if there are no hooks, because scripts will source it.
72-
err = writeInitHookFile(devbox, hooks, initHookTmpl, HooksFilename)
75+
err = writeRawInitHookFile(devbox, hooks)
7376
if err != nil {
7477
return errors.WithStack(err)
7578
}
76-
written[HooksFilename] = struct{}{}
77-
err = writeInitHookFile(devbox, hooks, initHookFishTmpl, HooksFishFilename)
79+
written[rawHooksFilename] = struct{}{}
80+
81+
err = writeInitHookWrapperFile(devbox)
7882
if err != nil {
7983
return errors.WithStack(err)
8084
}
81-
written[HooksFishFilename] = struct{}{}
85+
written[HooksFilename] = struct{}{}
8286

8387
// Write scripts to files.
8488
for name, body := range devbox.Config().Scripts() {
85-
err = WriteScriptFile(devbox, name, ScriptBody(devbox, body.String()))
89+
scriptBody, err := ScriptBody(devbox, body.String())
90+
if err != nil {
91+
return errors.WithStack(err)
92+
}
93+
err = WriteScriptFile(devbox, name, scriptBody)
8694
if err != nil {
8795
return errors.WithStack(err)
8896
}
@@ -103,29 +111,27 @@ func WriteScriptsToFiles(devbox devboxer) error {
103111
return nil
104112
}
105113

106-
func writeInitHookFile(devbox devboxer, body, tmpl, filename string) (err error) {
107-
script, err := createScriptFile(devbox, filename)
114+
func writeRawInitHookFile(devbox devboxer, body string) (err error) {
115+
script, err := createScriptFile(devbox, rawHooksFilename)
108116
if err != nil {
109117
return errors.WithStack(err)
110118
}
111119
defer script.Close() // best effort: close file
112120

113-
// skip adding init-hook recursion guard for the
114-
// default hook or any empty hook
115-
// there's nothing to guard, and it introduces complexity
116-
if body == devconfig.DefaultInitHook || strings.TrimSpace(body) == "" {
117-
_, err = script.WriteString(body)
118-
return errors.WithStack(err)
119-
}
121+
_, err = script.WriteString(body)
122+
return errors.WithStack(err)
123+
}
120124

121-
t, err := template.New(filename).Parse(tmpl)
125+
func writeInitHookWrapperFile(devbox devboxer) (err error) {
126+
script, err := createScriptFile(devbox, HooksFilename)
122127
if err != nil {
123128
return errors.WithStack(err)
124129
}
130+
defer script.Close() // best effort: close file
125131

126-
return t.Execute(script, map[string]any{
127-
"Body": body,
132+
return initHookWrapperTmpl.Execute(script, map[string]string{
128133
"InitHookHash": "__DEVBOX_INIT_HOOK_" + devbox.ProjectDirHash(),
134+
"RawHooksFile": ScriptPath(devbox.ProjectDir(), rawHooksFilename),
129135
})
130136
}
131137

@@ -167,6 +173,15 @@ func ScriptPath(projectDir, scriptName string) string {
167173
return filepath.Join(projectDir, scriptsDir, scriptName+".sh")
168174
}
169175

170-
func ScriptBody(d devboxer, body string) string {
171-
return fmt.Sprintf(". %s\n\n%s", ScriptPath(d.ProjectDir(), HooksFilename), body)
176+
func ScriptBody(d devboxer, body string) (string, error) {
177+
var buf bytes.Buffer
178+
err := scriptWrapperTmpl.Execute(&buf, map[string]string{
179+
"Body": body,
180+
"InitHookHash": "__DEVBOX_INIT_HOOK_" + d.ProjectDirHash(),
181+
"InitHookPath": ScriptPath(d.ProjectDir(), HooksFilename),
182+
})
183+
if err != nil {
184+
return "", errors.WithStack(err)
185+
}
186+
return buf.String(), nil
172187
}

internal/shellgen/tmpl/init-hook-fish.tmpl

Lines changed: 0 additions & 16 deletions
This file was deleted.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{{/*
2+
Wrapping the hooks ensures that any script called within doesn't trigger more
3+
init hooks.
4+
Code here should be fish and POSIX compatible. That's why we use export to
5+
remove the value
6+
*/ -}}
7+
export {{ .InitHookHash }}=true
8+
. {{ .RawHooksFile }}
9+
export {{ .InitHookHash }}=""

internal/shellgen/tmpl/init-hook.tmpl

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{{/*
2+
This wraps user scripts in devbox.json. The idea is to only run the init
3+
hooks once, even if the init hook calls devbox run again. This will also
4+
protect against using devbox service in the init hook.
5+
6+
Scripts always use sh to run, so POSIX is OK. We don't (yet) support fish
7+
scripts. (though users can run a fish script within their script)
8+
*/ -}}
9+
10+
if [ -z "${{ .InitHookHash }}" ]; then
11+
{{/* init hooks will export InitHookHash ensuring no recursive sourcing*/ -}}
12+
. {{ .InitHookPath }}
13+
fi
14+
15+
{{ .Body }}

0 commit comments

Comments
 (0)