|
3 | 3 | - `${env:SOME-ENV:default-value}`: environment variable with optional default |
4 | 4 | - `${file:/some/path}`: file content (relative paths resolved from cwd) |
5 | 5 | - `${classpath:path/to/file}`: classpath resource content |
6 | | - - `${netrc:api.provider.com}`: credential from Unix netrc files" |
| 6 | + - `${netrc:api.provider.com}`: credential from Unix netrc files |
| 7 | + - `${cmd:some command}`: stdout of an arbitrary shell command |
| 8 | +
|
| 9 | + For `${cmd:...}` on macOS, the inherited PATH for GUI-launched apps |
| 10 | + (Finder/Dock) is minimal, so commands like `pass` and `op` won't resolve. |
| 11 | + We spawn the user's interactive login shell once and capture its `$PATH` |
| 12 | + -- this picks up everything sourced from `.zshrc`, `.zprofile`, |
| 13 | + `.bash_profile`, etc. (Homebrew, mise/asdf shims, custom directories). |
| 14 | + The query result is cached for the process lifetime; on failure we fall |
| 15 | + back to a hardcoded augmentation with Homebrew/user-local paths." |
7 | 16 | (:require |
8 | 17 | [babashka.fs :as fs] |
| 18 | + [babashka.process :as p] |
9 | 19 | [clojure.java.io :as io] |
10 | 20 | [clojure.string :as string] |
11 | 21 | [eca.logger :as logger] |
|
17 | 27 |
|
18 | 28 | (defn get-env [env] (System/getenv env)) |
19 | 29 |
|
| 30 | +(def ^:private cmd-default-timeout-ms 30000) |
| 31 | +(def ^:private shell-query-timeout-ms 5000) |
| 32 | +(def ^:private shell-query-delim "__ECA_PATH_DELIM__") |
| 33 | + |
| 34 | +(def ^:private mac-default-path-entries |
| 35 | + ["/opt/homebrew/bin" "/usr/local/bin"]) |
| 36 | + |
| 37 | +(def ^:private supported-query-shells |
| 38 | + #{"bash" "zsh" "sh" "dash" "ksh"}) |
| 39 | + |
| 40 | +(defn ^:private mac? [^String os-name] |
| 41 | + (boolean (and os-name (string/starts-with? os-name "Mac")))) |
| 42 | + |
| 43 | +(defn ^:private windows? [^String os-name] |
| 44 | + (boolean (and os-name (string/starts-with? os-name "Windows")))) |
| 45 | + |
| 46 | +(defn ^:private posix-shell-name [^String shell-path] |
| 47 | + (some-> shell-path |
| 48 | + (string/replace #".*/" "") |
| 49 | + string/lower-case)) |
| 50 | + |
| 51 | +(defn supported-query-shell? |
| 52 | + "True when the shell's basename is a POSIX shell we know how to query |
| 53 | + (bash/zsh/sh/dash/ksh). Other shells (notably fish) use different syntax |
| 54 | + for `$PATH` expansion and are skipped -- we fall back to hardcoded |
| 55 | + augmentation for those users." |
| 56 | + [shell-path] |
| 57 | + (contains? supported-query-shells (posix-shell-name shell-path))) |
| 58 | + |
| 59 | +(defn augment-path |
| 60 | + "Pure helper. On macOS, returns a PATH string with Homebrew, /usr/local/bin |
| 61 | + and `<home>/.local/bin` prepended (only entries not already present in |
| 62 | + `existing-path`). On other OSes returns `existing-path` unchanged." |
| 63 | + [os-name existing-path home path-separator] |
| 64 | + (if (mac? os-name) |
| 65 | + (let [sep (or path-separator ":") |
| 66 | + existing (or existing-path "") |
| 67 | + existing-set (set (string/split existing |
| 68 | + (re-pattern (java.util.regex.Pattern/quote sep)))) |
| 69 | + extras (cond-> (vec mac-default-path-entries) |
| 70 | + home (conj (str home "/.local/bin"))) |
| 71 | + missing (vec (remove existing-set extras))] |
| 72 | + (cond |
| 73 | + (empty? missing) existing |
| 74 | + (string/blank? existing) (string/join sep missing) |
| 75 | + :else (str (string/join sep missing) sep existing))) |
| 76 | + existing-path)) |
| 77 | + |
| 78 | +(defn run-process! |
| 79 | + "Spawns `cmd-vector` via babashka.process with `extra-env` merged into the |
| 80 | + child env, deref'd with `timeout-ms`. Returns {:exit :out :err}. |
| 81 | + On timeout destroys the process tree and throws ex-info. |
| 82 | +
|
| 83 | + Public so tests can `with-redefs` it without spawning real subprocesses." |
| 84 | + [cmd-vector extra-env timeout-ms] |
| 85 | + (let [proc (p/process {:cmd cmd-vector |
| 86 | + :out :string |
| 87 | + :err :string |
| 88 | + :continue true |
| 89 | + :extra-env extra-env}) |
| 90 | + result (deref proc timeout-ms ::timeout)] |
| 91 | + (if (= result ::timeout) |
| 92 | + (do |
| 93 | + (p/destroy-tree proc) |
| 94 | + (throw (ex-info "Command timed out" |
| 95 | + {:cmd cmd-vector :timeout-ms timeout-ms}))) |
| 96 | + result))) |
| 97 | + |
| 98 | +(defn query-user-shell-path* |
| 99 | + "Spawns `shell-path` as `<shell> -ilc 'printf DELIM%sDELIM \"$PATH\"'` (an |
| 100 | + interactive login shell, which sources the user's rc/profile files) and |
| 101 | + extracts the captured PATH from between the delimiters. Returns the PATH |
| 102 | + string or nil on any failure (unsupported shell, non-zero exit, missing |
| 103 | + delimiter, exception/timeout). Empty PATH is treated as failure. |
| 104 | +
|
| 105 | + Public for testing -- the production path goes through `query-user-shell-path` |
| 106 | + which reads `$SHELL` from the environment." |
| 107 | + [shell-path] |
| 108 | + (try |
| 109 | + (when (supported-query-shell? shell-path) |
| 110 | + (let [delim shell-query-delim |
| 111 | + script (str "printf '" delim "%s" delim "' \"$PATH\"") |
| 112 | + {:keys [exit out]} (run-process! [shell-path "-ilc" script] |
| 113 | + {} |
| 114 | + shell-query-timeout-ms) |
| 115 | + quoted-delim (java.util.regex.Pattern/quote delim)] |
| 116 | + (when (and (zero? exit) (string? out)) |
| 117 | + (when-let [[_ path] (re-find (re-pattern (str "(?s)" quoted-delim "(.*?)" quoted-delim)) |
| 118 | + out)] |
| 119 | + (when (seq path) path))))) |
| 120 | + (catch Exception e |
| 121 | + (logger/debug logger-tag "Shell PATH query failed:" (.getMessage e)) |
| 122 | + nil))) |
| 123 | + |
| 124 | +(defn query-user-shell-path |
| 125 | + "Reads `$SHELL` from the environment (defaulting to /bin/bash) and queries it |
| 126 | + for the user's PATH. Returns the PATH string or nil on failure. Public for |
| 127 | + test redefinition." |
| 128 | + [] |
| 129 | + (query-user-shell-path* (or (System/getenv "SHELL") "/bin/bash"))) |
| 130 | + |
| 131 | +(defonce ^:private user-shell-path-cache* (atom ::unset)) |
| 132 | + |
| 133 | +(defn user-shell-path |
| 134 | + "Returns the user's interactive shell PATH or nil. Lazily queried on first |
| 135 | + call and cached for the process lifetime. Both successful and unsuccessful |
| 136 | + results are cached -- we don't retry the shell query on every miss." |
| 137 | + [] |
| 138 | + (let [v @user-shell-path-cache*] |
| 139 | + (if (= v ::unset) |
| 140 | + (let [p (query-user-shell-path)] |
| 141 | + (reset! user-shell-path-cache* (or p ::miss)) |
| 142 | + p) |
| 143 | + (when-not (= v ::miss) v)))) |
| 144 | + |
| 145 | +(defn reset-shell-path-cache! |
| 146 | + "Clears the cached shell PATH. Test helper." |
| 147 | + [] |
| 148 | + (reset! user-shell-path-cache* ::unset)) |
| 149 | + |
| 150 | +(defn effective-path |
| 151 | + "Returns the PATH to use for the cmd resolver child process. |
| 152 | +
|
| 153 | + On macOS, prefers the user's interactive shell PATH (queried once and cached); |
| 154 | + falls back to augmenting the inherited PATH with hardcoded Homebrew/user-local |
| 155 | + entries when the shell query is unavailable. On other OSes returns |
| 156 | + `existing-path` unchanged." |
| 157 | + [os-name existing-path home sep] |
| 158 | + (if (mac? os-name) |
| 159 | + (or (user-shell-path) |
| 160 | + (augment-path os-name existing-path home sep)) |
| 161 | + existing-path)) |
| 162 | + |
| 163 | +(defn ^:private build-cmd-vector [^String os-name cmd-string] |
| 164 | + (if (windows? os-name) |
| 165 | + ["powershell.exe" "-NoProfile" "-Command" cmd-string] |
| 166 | + ["bash" "-c" cmd-string])) |
| 167 | + |
| 168 | +(defn resolve-cmd |
| 169 | + "Runs `cmd-string` via the platform shell (bash on POSIX, PowerShell on Windows) |
| 170 | + and returns its stdout with trailing whitespace trimmed. |
| 171 | +
|
| 172 | + On non-zero exit, exception, or timeout: logs a warning and returns \"\" -- |
| 173 | + matching the failure mode of the other dynamic string backends so a missing |
| 174 | + secret does not crash config load." |
| 175 | + [cmd-string] |
| 176 | + (try |
| 177 | + (let [os-name (System/getProperty "os.name") |
| 178 | + existing-path (System/getenv "PATH") |
| 179 | + home (System/getProperty "user.home") |
| 180 | + sep (System/getProperty "path.separator") |
| 181 | + new-path (effective-path os-name existing-path home sep) |
| 182 | + extra-env (cond-> {} |
| 183 | + (and new-path (not= new-path existing-path)) |
| 184 | + (assoc "PATH" new-path)) |
| 185 | + {:keys [exit out err]} (run-process! (build-cmd-vector os-name cmd-string) |
| 186 | + extra-env |
| 187 | + cmd-default-timeout-ms)] |
| 188 | + (if (zero? exit) |
| 189 | + (some-> out string/trimr) |
| 190 | + (do |
| 191 | + (logger/warn logger-tag |
| 192 | + "Command exited non-zero:" |
| 193 | + {:cmd cmd-string |
| 194 | + :exit exit |
| 195 | + :err (some-> err string/trim)}) |
| 196 | + ""))) |
| 197 | + (catch Exception e |
| 198 | + (logger/warn logger-tag |
| 199 | + "Command failed:" |
| 200 | + {:cmd cmd-string :error (.getMessage e)}) |
| 201 | + ""))) |
| 202 | + |
20 | 203 | (defn replace-dynamic-strings |
21 | 204 | "Given a string and a current working directory, look for patterns replacing its content: |
22 | 205 | - `${env:SOME-ENV:default-value}`: Replace with a env falling back to a optional default value |
23 | 206 | - `${file:/some/path}`: Replace with a file content checking from cwd if relative |
24 | 207 | - `${classpath:path/to/file}`: Replace with a file content found checking classpath |
25 | | - - `${netrc:api.provider.com}`: Replace with the content from Unix net RC [credential files](https://eca.dev/config/models/#credential-file-authentication)" |
| 208 | + - `${netrc:api.provider.com}`: Replace with the content from Unix net RC [credential files](https://eca.dev/config/models/#credential-file-authentication) |
| 209 | + - `${cmd:some command}`: Replace with the trimmed stdout of an arbitrary shell command" |
26 | 210 | [s cwd config] |
27 | 211 | (some-> s |
28 | 212 | (string/replace #"\$\{env:([^:}]+)(?::([^}]*))?\}" |
|
53 | 237 | (or (secrets/get-credential key-rc (get config "netrcFile")) "") |
54 | 238 | (catch Exception e |
55 | 239 | (logger/warn logger-tag "Error reading netrc credential:" (.getMessage e)) |
| 240 | + "")))) |
| 241 | + (string/replace #"\$\{cmd:([^}]+)\}" |
| 242 | + (fn [[_match cmd-string]] |
| 243 | + (try |
| 244 | + (or (resolve-cmd cmd-string) "") |
| 245 | + (catch Exception e |
| 246 | + (logger/warn logger-tag "Error executing cmd:" (.getMessage e)) |
56 | 247 | "")))))) |
0 commit comments