Skip to content

Commit 7dd4606

Browse files
authored
Add cookbook chapter on how to read env vars from foreign shell scripts (#1292)
* Add cookbook chapter on how to read env vars from other shell scripts * Some editorial changes to new cookbook chapter, pr feedback
1 parent 9a2a119 commit 7dd4606

File tree

2 files changed

+246
-0
lines changed

2 files changed

+246
-0
lines changed

.vuepress/configs/sidebar/en.ts

+1
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,7 @@ export const sidebarEn: SidebarConfig = {
137137
'help',
138138
'system',
139139
'parsing',
140+
'foreign_shell_scripts',
140141
'pattern_matching',
141142
'external_completers',
142143
'files',

cookbook/foreign_shell_scripts.md

+245
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
---
2+
title: Foreign Shell Scripts
3+
---
4+
5+
# Working With Foreign Shell Scripts
6+
7+
A common issue with nu is, that other applications export environment variables
8+
or functionality as shell scripts, that are expected to then be evaluated by
9+
your shell.
10+
11+
But many applications only consider the most commonly used shells like `bash` or
12+
`zsh`. Unfortunately, nu has entirely incompatible syntax with these shells, so
13+
it cannot run or `source` these scripts directly.
14+
15+
Generally nothing stops you from running a `zsh` script by invoking `zsh` itself
16+
(given it is installed). But unfortunately this will not allow nu to access
17+
exported environment variables:
18+
19+
```nu
20+
# This works, using zsh to print "Hello"
21+
'echo Hello' | zsh -c $in
22+
23+
# This exits with an error because $env.VAR is not defined
24+
'export VAR="Hello"' | zsh -c $in
25+
print $env.VAR
26+
```
27+
28+
This chapter presents two workarounds for getting around this issue, and the
29+
involved drawbacks.
30+
31+
---
32+
33+
## Parsing a Script as a String
34+
35+
A naive workaround to extract environment variable declarations is to read the
36+
foreign script as a string and parse anything that looks like a variable
37+
declaration, so it can be loaded into nushell's environment.
38+
39+
```nu
40+
let bash_greeting = '
41+
export GREETING="Hello";
42+
export FROM="from bash";
43+
'
44+
45+
load-env (
46+
$bash_greeting
47+
| str trim
48+
| lines
49+
| parse 'export {name}="{value}";'
50+
| transpose --header-row --as-record
51+
)
52+
53+
print $"($env.GREETING) ($env.FROM)" # "Hello from bash"
54+
```
55+
56+
This is perfectly fine for situations where you are sure of the exact format of
57+
the script and can predict parsing edge cases.
58+
59+
This quickly gets tricky though, for example when the script is declaring a
60+
`PATH` variable that references its previous value
61+
(`export PATH="$PATH:/extra/path";`).
62+
63+
There are ways to implement some form of expansion too, but at some point it
64+
might make more sense to leave the parsing to the shell it was meant for.
65+
66+
## Capturing the environment from a foreign shell script
67+
68+
A more complex approach is to run the script in the shell it is written for and
69+
then do some hackery to capture the script's variables afterwards.
70+
71+
Note: The shown command assumes a Unix-like operating system, it may also be
72+
possible to implement one for Windows that could capture variables from a
73+
PowerShell script.
74+
75+
```nu
76+
# Returns a record of changed env variables after running a non-nushell script's contents (passed via stdin), e.g. a bash script you want to "source"
77+
def capture-foreign-env [
78+
--shell (-s): string = /bin/sh
79+
# The shell to run the script in
80+
# (has to support '-c' argument and POSIX 'env', 'echo', 'eval' commands)
81+
--arguments (-a): list<string> = []
82+
# Additional command line arguments to pass to the foreign shell
83+
] {
84+
let script_contents = $in;
85+
let env_out = with-env { SCRIPT_TO_SOURCE: $script_contents } {
86+
^$shell ...$arguments -c `
87+
env
88+
echo '<ENV_CAPTURE_EVAL_FENCE>'
89+
eval "$SCRIPT_TO_SOURCE"
90+
echo '<ENV_CAPTURE_EVAL_FENCE>'
91+
env -u _ -u _AST_FEATURES -u SHLVL` # Filter out known changing variables
92+
}
93+
| split row '<ENV_CAPTURE_EVAL_FENCE>'
94+
| {
95+
before: ($in | first | str trim | lines)
96+
after: ($in | last | str trim | lines)
97+
}
98+
99+
# Unfortunate Assumption:
100+
# No changed env var contains newlines (not cleanly parseable)
101+
$env_out.after
102+
| where { |line| $line not-in $env_out.before } # Only get changed lines
103+
| parse "{key}={value}"
104+
| transpose --header-row --as-record
105+
}
106+
```
107+
108+
Usage, e.g. in `env.nu`:
109+
110+
```nu
111+
# Default usage, running the script with `/bin/sh`
112+
load-env (open script.sh | capture-foreign-env)
113+
114+
# Running a different shell's script
115+
# fish might be elsewhere on your system, if it's in the PATH, `fish` is enough
116+
load-env (open script.fish | capture-foreign-env --shell /usr/local/bin/fish)
117+
```
118+
119+
The command runs a foreign shell script and captures the changed environment
120+
variables after running the script. This is done by parsing output of the `env`
121+
command available on unix-like systems. The shell to execute can be specified
122+
and configured using the `--shell` and `--arguments` parameters, the command has
123+
been tested using sh (-> bash), bash, zsh, fish, ksh, and dash.
124+
125+
::: warning
126+
A caveat for this approach is that it requires all changed environment variables
127+
not to include newline characters, as the UNIX `env` output is not cleanly
128+
parseable in that case.
129+
130+
Also beware that directly passing the output of `capture-foreign-env` to
131+
`load-env` can result in changed variables like `PATH` to become strings again,
132+
even if they have been converted to a list before.
133+
:::
134+
135+
### Detailed Explanation of `capture-foreign-env`
136+
137+
Let's have a look at the command's signature first:
138+
139+
```nu
140+
def capture-foreign-env [
141+
--shell (-s): string = /bin/sh
142+
# The shell to run the script in
143+
# (has to support '-c' argument and POSIX 'env', 'echo', 'eval' commands)
144+
--arguments (-a): list<string> = []
145+
# Additional command line arguments to pass to the foreign shell
146+
] {
147+
let script_contents = $in;
148+
# ...
149+
}
150+
```
151+
152+
We're declaring a custom command that takes two optional flags:
153+
154+
- `--shell` to specify a shell to run the script in, (e.g. `bash`)
155+
- `--arguments` to parse further command line arguments to that shell.
156+
157+
The actual script is not mentioned here, because it is read using the special
158+
`$in` variable that represents anything passed to Standard Input (`stdin`), e.g.
159+
via a pipe.
160+
161+
The shell is set to `/bin/sh` by default, because this is often considered the
162+
"default" POSIX-compatible shell of UNIX-like systems, e.g. macOS or Linux. It
163+
is often not running the original Bourne shell (`sh`), but linking to a
164+
different shell, like `bash`, with some compatibility flags turned on.
165+
166+
As such, many "generic" shell scripts to source are compatible with the system'
167+
s `/bin/sh`.
168+
169+
Now, let's have a look at where the shell is actually run:
170+
171+
```nu
172+
let env_out = with-env { SCRIPT_TO_SOURCE: $script_contents } {
173+
^$shell ...$arguments -c ` ... `
174+
}
175+
```
176+
177+
Essentially, this calls the specified shell (using `^` to run the value as a
178+
command) with any arguments specified. It also passes `-c` with an inlined
179+
script for the shell, which is the syntax to immediately execute a passed script
180+
and exit in most shells.
181+
182+
The `with-env { SCRIPT_TO_SOURCE: $script_contents }` block defines an
183+
additional environment variable with the actual script we want to run. This is
184+
used to pass the script in an unescaped string form, where the executing shell is entirely responsible for parsing it. The alternatives would have been:
185+
186+
- Passing the script via `-c $script`, but then we couldn't (safely) add our own
187+
commands to log out the environment variables after the script ran.
188+
- Using string interpolation, but then we would be responsible for fully
189+
escaping the script, so that the `eval "($script)"` line doesn't break due to
190+
quotation marks. With the variable expansion in the foreign shell, that shell
191+
does not need the value to be escaped; just as nu is normally able to pass a
192+
string with any contents to a command as a single string argument.
193+
- Using a (temporary or existing) file containing the script - This would also
194+
work, but seems unnecessary and potentially slower.
195+
196+
Then the external shell executes the script we passed:
197+
198+
```bash
199+
env
200+
echo '<ENV_CAPTURE_EVAL_FENCE>'
201+
eval "$SCRIPT_TO_SOURCE"
202+
echo '<ENV_CAPTURE_EVAL_FENCE>'
203+
env -u _ -u _AST_FEATURES -u SHLVL
204+
```
205+
206+
These POSIX-shell compatible commands, available in UNIX-like OSes, do the
207+
following:
208+
209+
1. Log out all environment variables at the start of the script. These may be
210+
different than the ones in nushell, because the shell might have defined
211+
variables on startup and all passed-in variables have been serialized to
212+
strings by nushell.
213+
2. Log `<ENV_CAPTURE_EVAL_FENCE>` to stdout, this is so we later know where the
214+
first `env` output stopped. The content of this is arbitrary, but it is
215+
verbose to reduce the risk of any env var having this string in its contents.
216+
3. Run the actual shell script in the current context, using `eval`. The double
217+
quotes around the variable are necessary to get newlines to be interpreted
218+
correctly.
219+
4. Log the "fence" again to stdout so we know where the "after" list of
220+
variables starts.
221+
5. Log all environment variables after the script run. We are excluding a few
222+
variables here that are commonly changed by a few shells that have nothing to
223+
do with the particular script that was run.
224+
225+
We then take the script output and save all lines from the `env` output before
226+
and after running the passed script, using the `<ENV_CAPTURE_EVAL_FENCE>` logs.
227+
228+
```nu
229+
# <shell invocation>
230+
| split row '<ENV_CAPTURE_EVAL_FENCE>'
231+
| {
232+
before: ($in | first | str trim | lines)
233+
after: ($in | last | str trim | lines)
234+
}
235+
```
236+
237+
Finally, all that is left to do is to take all env-output lines from the "after"
238+
output that were not there before, and parse them into a record:
239+
240+
```nu
241+
$env_out.after
242+
| where { |line| $line not-in $env_out.before } # Only get changed lines
243+
| parse "{key}={value}"
244+
| transpose --header-row --as-record
245+
```

0 commit comments

Comments
 (0)