|
| 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