Skip to content

Commit fe1db1f

Browse files
authored
[direnv] copy bash, fish, zsh shell code from direnv (#1156)
## Summary **Overall Motivation** We've been having issues with the bin-wrappers, and want to explore an alternate approach. Bin-wrappers ensure that the shell environment is completely updated prior to invoking the binary, via running `devbox shellenv` if needed. This alternate approach takes inspiration from direnv. It will seek to register a prompt `hook` in the shell. The hook will execute a shell function that will `export` the shell environment to update it, prior to displaying the shell prompt. To punt on how to transition existing devbox commands, we're introducing `devbox hook` and `devbox export` as distinct commands, analogous to `direnv hook` and `direnv export`. **This PR's motivation** As part of implementing this, we'd like to make use of direnv's battle-tested shell code where we can. Direnv has a nice shell abstraction for handling the hook and environment-variables. We could adopt this abstraction to better support shell code we generate beyond this immediate use-case of prompt-hooks. So, we: 1. Copy bash, fish, zsh `Shell` code from direnv. Thank you 🙏 2. Add ksh, posix, unknownSh structs. I filled in the struct functions as placeholders for now. I call the package `shenv` since it handles the "shell environment". I stayed away from "shellenv" to avoid confusion with our existing "shellenv" command's operation. ## How was it tested? Have _not_ tested. Subsequent PR will test and correct as needed.
1 parent d5271a0 commit fe1db1f

File tree

8 files changed

+489
-0
lines changed

8 files changed

+489
-0
lines changed

internal/shenv/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
Code in this directory was copy-pasted from the direnv codebase:
2+
https://github.com/direnv/direnv/blob/master/internal/cmd/
3+
4+
We could not directly import this code because in the direnv
5+
code it is inside an `internal` directory, and hence not
6+
exported.
7+
8+
Full credit to the direnv authors.

internal/shenv/shell_bash.go

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package shenv
2+
3+
import "fmt"
4+
5+
type bash struct{}
6+
7+
// Bash shell instance
8+
var Bash Shell = bash{}
9+
10+
const bashHook = `
11+
_devbox_hook() {
12+
local previous_exit_status=$?;
13+
trap -- '' SIGINT;
14+
eval "$(devbox shellenv --config {{ .ProjectDir }})";
15+
trap - SIGINT;
16+
return $previous_exit_status;
17+
};
18+
if ! [[ "${PROMPT_COMMAND:-}" =~ _devbox_hook ]]; then
19+
PROMPT_COMMAND="_devbox_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
20+
fi
21+
`
22+
23+
func (sh bash) Hook() (string, error) {
24+
return bashHook, nil
25+
}
26+
27+
func (sh bash) Export(e ShellExport) (out string) {
28+
for key, value := range e {
29+
if value == nil {
30+
out += sh.unset(key)
31+
} else {
32+
out += sh.export(key, *value)
33+
}
34+
}
35+
return out
36+
}
37+
38+
func (sh bash) Dump(env Env) (out string) {
39+
for key, value := range env {
40+
out += sh.export(key, value)
41+
}
42+
return out
43+
}
44+
45+
func (sh bash) export(key, value string) string {
46+
return "export " + sh.escape(key) + "=" + sh.escape(value) + ";"
47+
}
48+
49+
func (sh bash) unset(key string) string {
50+
return "unset " + sh.escape(key) + ";"
51+
}
52+
53+
func (sh bash) escape(str string) string {
54+
return BashEscape(str)
55+
}
56+
57+
/*
58+
* Escaping
59+
*/
60+
61+
// nolint
62+
const (
63+
ACK = 6
64+
TAB = 9
65+
LF = 10
66+
CR = 13
67+
US = 31
68+
SPACE = 32
69+
AMPERSTAND = 38
70+
SINGLE_QUOTE = 39
71+
PLUS = 43
72+
NINE = 57
73+
QUESTION = 63
74+
UPPERCASE_Z = 90
75+
OPEN_BRACKET = 91
76+
BACKSLASH = 92
77+
UNDERSCORE = 95
78+
CLOSE_BRACKET = 93
79+
BACKTICK = 96
80+
LOWERCASE_Z = 122
81+
TILDA = 126
82+
DEL = 127
83+
)
84+
85+
// https://github.com/solidsnack/shell-escape/blob/master/Text/ShellEscape/Bash.hs
86+
/*
87+
A Bash escaped string. The strings are wrapped in @$\'...\'@ if any
88+
bytes within them must be escaped; otherwise, they are left as is.
89+
Newlines and other control characters are represented as ANSI escape
90+
sequences. High bytes are represented as hex codes. Thus Bash escaped
91+
strings will always fit on one line and never contain non-ASCII bytes.
92+
*/
93+
func BashEscape(str string) string {
94+
if str == "" {
95+
return "''"
96+
}
97+
// var too short
98+
//nolint:varnamelen
99+
in := []byte(str)
100+
out := ""
101+
i := 0
102+
// var too short
103+
//nolint:varnamelen
104+
l := len(in)
105+
escape := false
106+
107+
hex := func(char byte) {
108+
escape = true
109+
out += fmt.Sprintf("\\x%02x", char)
110+
}
111+
112+
backslash := func(char byte) {
113+
escape = true
114+
out += string([]byte{BACKSLASH, char})
115+
}
116+
117+
escaped := func(str string) {
118+
escape = true
119+
out += str
120+
}
121+
122+
quoted := func(char byte) {
123+
escape = true
124+
out += string([]byte{char})
125+
}
126+
127+
literal := func(char byte) {
128+
out += string([]byte{char})
129+
}
130+
131+
for i < l {
132+
char := in[i]
133+
switch {
134+
case char == ACK:
135+
hex(char)
136+
case char == TAB:
137+
escaped(`\t`)
138+
case char == LF:
139+
escaped(`\n`)
140+
case char == CR:
141+
escaped(`\r`)
142+
case char <= US:
143+
hex(char)
144+
case char <= AMPERSTAND:
145+
quoted(char)
146+
case char == SINGLE_QUOTE:
147+
backslash(char)
148+
case char <= PLUS:
149+
quoted(char)
150+
case char <= NINE:
151+
literal(char)
152+
case char <= QUESTION:
153+
quoted(char)
154+
case char <= UPPERCASE_Z:
155+
literal(char)
156+
case char == OPEN_BRACKET:
157+
quoted(char)
158+
case char == BACKSLASH:
159+
backslash(char)
160+
case char == UNDERSCORE:
161+
literal(char)
162+
case char <= CLOSE_BRACKET:
163+
quoted(char)
164+
case char <= BACKTICK:
165+
quoted(char)
166+
case char <= TILDA:
167+
quoted(char)
168+
case char == DEL:
169+
hex(char)
170+
default:
171+
hex(char)
172+
}
173+
i++
174+
}
175+
176+
if escape {
177+
out = "$'" + out + "'"
178+
}
179+
180+
return out
181+
}

internal/shenv/shell_fish.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package shenv
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
type fish struct{}
9+
10+
// Fish adds support for the fish shell as a host
11+
var Fish Shell = fish{}
12+
13+
const fishHook = `
14+
function __devbox_shellenv_eval --on-event fish_prompt;
15+
devbox shellenv --config {{ .ProjectDir }} | source;
16+
end;
17+
`
18+
19+
func (sh fish) Hook() (string, error) {
20+
return fishHook, nil
21+
}
22+
23+
func (sh fish) Export(e ShellExport) (out string) {
24+
for key, value := range e {
25+
if value == nil {
26+
out += sh.unset(key)
27+
} else {
28+
out += sh.export(key, *value)
29+
}
30+
}
31+
return out
32+
}
33+
34+
func (sh fish) Dump(env Env) (out string) {
35+
for key, value := range env {
36+
out += sh.export(key, value)
37+
}
38+
return out
39+
}
40+
41+
func (sh fish) export(key, value string) string {
42+
if key == "PATH" {
43+
command := "set -x -g PATH"
44+
for _, path := range strings.Split(value, ":") {
45+
command += " " + sh.escape(path)
46+
}
47+
return command + ";"
48+
}
49+
return "set -x -g " + sh.escape(key) + " " + sh.escape(value) + ";"
50+
}
51+
52+
func (sh fish) unset(key string) string {
53+
return "set -e -g " + sh.escape(key) + ";"
54+
}
55+
56+
func (sh fish) escape(str string) string {
57+
// var too short
58+
//nolint:varnamelen
59+
in := []byte(str)
60+
out := "'"
61+
i := 0
62+
// var too short
63+
//nolint:varnamelen
64+
l := len(in)
65+
66+
hex := func(char byte) {
67+
out += fmt.Sprintf("'\\X%02x'", char)
68+
}
69+
70+
backslash := func(char byte) {
71+
out += string([]byte{BACKSLASH, char})
72+
}
73+
74+
escaped := func(str string) {
75+
out += "'" + str + "'"
76+
}
77+
78+
literal := func(char byte) {
79+
out += string([]byte{char})
80+
}
81+
82+
for i < l {
83+
char := in[i]
84+
switch {
85+
case char == TAB:
86+
escaped(`\t`)
87+
case char == LF:
88+
escaped(`\n`)
89+
case char == CR:
90+
escaped(`\r`)
91+
case char <= US:
92+
hex(char)
93+
case char == SINGLE_QUOTE:
94+
backslash(char)
95+
case char == BACKSLASH:
96+
backslash(char)
97+
case char <= TILDA:
98+
literal(char)
99+
case char == DEL:
100+
hex(char)
101+
default:
102+
hex(char)
103+
}
104+
i++
105+
}
106+
107+
out += "'"
108+
109+
return out
110+
}

internal/shenv/shell_ksh.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package shenv
2+
3+
type ksh struct{}
4+
5+
// Ksh adds support the korn shell
6+
var Ksh Shell = ksh{}
7+
8+
// um, this is ChatGPT writing it. I need to verify and test
9+
const kshHook = `
10+
_devbox_hook() {
11+
eval "$(devbox shellenv --config {{ .ProjectDir }})";
12+
}
13+
if [[ "$(typeset -f precmd)" != *"_devbox_hook"* ]]; then
14+
function precmd {
15+
devbox_hook
16+
}
17+
fi
18+
`
19+
20+
func (sh ksh) Hook() (string, error) {
21+
return kshHook, nil
22+
}
23+
24+
func (sh ksh) Export(e ShellExport) (out string) {
25+
panic("not implemented")
26+
}
27+
28+
func (sh ksh) Dump(env Env) (out string) {
29+
panic("not implemented")
30+
}

internal/shenv/shell_posix.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package shenv
2+
3+
type posix struct{}
4+
5+
// Posix adds support for posix-compatible shells
6+
// Specifically, in the context of devbox, this includes
7+
// `dash`, `ash`, and `shell`
8+
var Posix Shell = posix{}
9+
10+
// um, this is ChatGPT writing it. I need to verify and test
11+
const posixHook = `
12+
_devbox_hook() {
13+
local previous_exit_status=$?
14+
trap : INT
15+
eval "$(devbox shellenv --config {{ .ProjectDir }})"
16+
trap - INT
17+
return $previous_exit_status
18+
}
19+
if [ -z "$PROMPT_COMMAND" ] || ! printf "%s" "$PROMPT_COMMAND" | grep -q "_devbox_hook"; then
20+
PROMPT_COMMAND="_devbox_hook${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
21+
fi
22+
`
23+
24+
func (sh posix) Hook() (string, error) {
25+
return posixHook, nil
26+
}
27+
28+
func (sh posix) Export(e ShellExport) (out string) {
29+
panic("not implemented")
30+
}
31+
32+
func (sh posix) Dump(env Env) (out string) {
33+
panic("not implemented")
34+
}

internal/shenv/shell_unknown.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package shenv
2+
3+
type unknown struct{}
4+
5+
// UnknownSh adds support the unknown shell. This serves
6+
// as a fallback alternative to outright failure.
7+
var UnknownSh Shell = unknown{}
8+
9+
const unknownHook = `
10+
echo "Warning: this shell will not update its environment.
11+
Please exit and re-enter shell after making any changes that may affect the devbox generated environment.\n"
12+
`
13+
14+
func (sh unknown) Hook() (string, error) {
15+
return unknownHook, nil
16+
}
17+
18+
func (sh unknown) Export(e ShellExport) (out string) {
19+
panic("not implemented")
20+
}
21+
22+
func (sh unknown) Dump(env Env) (out string) {
23+
panic("not implemented")
24+
}

0 commit comments

Comments
 (0)