Skip to content

Commit fcd420c

Browse files
committed
Correct dotenv watch semantics and refactor
When using the dotenv functionality I have noticed that we were not correctly handling file creation and deletion and decided to fix it. This patch makes sure that we do the following no matter the initial state (aka, it does not matter if the files are missing when you launch the pad): | .env | .env.local | Values From | | ----- |----------- | ------------ | | ✅ | ✅ | `.env.local` | | ✅ | ❌ | `.env` | | ❌ | ✅ | `.env.local` | | ❌ | ❌ | latest state | The last line means we currently do not keep track and therefore cannot remove already set environment variables. The patch also refactors the code a bit to remove code duplication: * `lamdbaisland.launchpad.env` is babashka-compatible and can is required into `lambdaistland.launchpad` in order to populate `:env`. * `lamdbaisland.launchpad.env.hacks`: is now the one evaluated by the watcher, uses `com.lamdbaisland.classpath` and contains the "nitty-gritty" details.
1 parent bfc8d60 commit fcd420c

4 files changed

Lines changed: 132 additions & 92 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,16 @@
1111
## Fixed
1212

1313
- Add an `:nrepl.middleware/descriptor` to silence the warning
14+
- Correct dotenv watch semantics. No matter the initial state we fix the behavior to achieve:
15+
16+
| .env | .env.local | Values From |
17+
| ----- |----------- | ------------ |
18+
||| `.env.local` |
19+
||| `.env` |
20+
||| `.env.local` |
21+
||| latest state |
22+
23+
The last line means we currently do not keep track and therefore cannot remove already set environment variables.
1424

1525
# 0.36.159-alpha (2025-02-09 / eafa135)
1626

src/lambdaisland/launchpad.clj

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
(ns lambdaisland.launchpad
22
(:require
3-
[babashka.process :refer [process]]
43
[babashka.wait :as wait]
54
[clojure.edn :as edn]
65
[clojure.java.io :as io]
76
[clojure.java.shell :as shell]
8-
[clojure.pprint :as pprint]
97
[clojure.string :as str]
10-
[lambdaisland.dotenv :as dotenv]
8+
[lambdaisland.launchpad.env :as env]
119
[lambdaisland.launchpad.log :refer :all]
1210
[lambdaisland.cli :as cli])
1311
(:import
@@ -395,22 +393,27 @@
395393
;; FIXME: this means we don't "see" deps.local.edn if it
396394
;; gets created after launchpad started, we can do
397395
;; better than that.
398-
['(lambdaisland.classpath.watch-deps/canonical-path "deps.local.edn")]
399-
[])
396+
['(lambdaisland.classpath.watch-deps/canonical-path "deps.local.edn")])
400397
:launchpad/extra-deps `'~(:extra-deps <>)}))))
401398

402399
(defn watch-dotenv [ctx]
403400
(-> ctx
404401
(assoc-extra-dep 'com.github.jnr/jnr-posix)
402+
(assoc-extra-dep 'com.lambdaisland/classpath)
405403
(update :java-args conj
406404
"--add-opens=java.base/java.lang=ALL-UNNAMED"
407405
"--add-opens=java.base/java.util=ALL-UNNAMED")
408-
(update :env #(apply merge % (map (fn [p]
409-
(when (.exists (io/file p))
410-
(dotenv/parse-dotenv (slurp p))))
411-
[".env" ".env.local"])))
412-
(update :requires conj 'lambdaisland.launchpad.env)
413-
(register-watch-handlers '(lambdaisland.launchpad.env/watch-handlers))))
406+
(update :env #(->> env/watch-paths
407+
(map env/->path)
408+
(filter env/exists?)
409+
(map env/parse-dotenv)
410+
(apply merge %)))
411+
(update :requires concat ['lambdaisland.launchpad.env 'lambdaisland.launchpad.env.hacks])
412+
(register-watch-handlers
413+
`(lambdaisland.launchpad.env.hacks/watch-handlers
414+
{:watch-paths ~(mapv #(list '.resolve 'lambdaisland.classpath.watch-deps/process-root-path %)
415+
env/watch-paths)
416+
:parse-fn 'lambdaisland.launchpad.env/parse-dotenv}))))
414417

415418
(defn start-shadow-build [{:keys [deps-edn aliases] :as ctx}]
416419
(let [build-ids (->> aliases

src/lambdaisland/launchpad/env.clj

Lines changed: 20 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1,88 +1,27 @@
11
(ns lambdaisland.launchpad.env
2-
"Make environment variables modifiable from within Java, and use that to watch a
3-
.env file for changes, and hot reload them.
4-
5-
This is *very* dirty, it uses reflection to get at various private bits of
6-
Java, it relies on implementation details of OpenJDK, and it requires breaking
7-
module isolation (the process has to start with
8-
`--add-opens=java.base/java.lang=ALL-UNNAMED`
9-
`--add-opens=java.base/java.util=ALL-UNNAMED`). We also rely on jnr-posix to
10-
get to the underlying setenv system call, for good measure.
11-
12-
But hey it works!"
13-
(:require [lambdaisland.dotenv :as dotenv]
14-
[lambdaisland.classpath.watch-deps :as watch-deps])
15-
(:import (java.nio.file Path Files LinkOption)))
16-
17-
(set! *warn-on-reflection* true)
18-
19-
(defn accessible-field ^java.lang.reflect.Field [^Class klz field]
20-
(doto (.getDeclaredField klz field)
21-
(.setAccessible true)))
22-
23-
(defn get-static [field]
24-
(let [klz (Class/forName (namespace field))]
25-
(.get (accessible-field klz
26-
(name field)) klz)))
27-
28-
(defn get-field [^Object instance field]
29-
(.get (accessible-field (.getClass instance) (str field)) instance))
30-
31-
(defn set-field! [klz field obj val]
32-
(.set (accessible-field klz field) obj val))
33-
34-
(defn set-static! [klz field val]
35-
(set-field! klz field klz val))
36-
37-
(def ^java.util.Map theEnvironment
38-
(get-static 'java.lang.ProcessEnvironment/theEnvironment))
39-
40-
(def ^java.lang.ProcessEnvironment$StringEnvironment theUnmodifiableEnvironment
41-
(get-field (get-static 'java.lang.ProcessEnvironment/theUnmodifiableEnvironment) 'm))
42-
43-
(def ^jnr.posix.POSIX posix (jnr.posix.POSIXFactory/getPOSIX))
44-
45-
(defn new-value [^String str]
46-
(assert (= -1 (.indexOf str "\u0000")))
47-
(let [^java.lang.reflect.Constructor init
48-
(first (.getDeclaredConstructors java.lang.ProcessEnvironment$Value))]
49-
(.setAccessible init true)
50-
(.newInstance init (into-array Object ["XXX" (.getBytes "XXX")]))))
51-
52-
(defn new-variable [^String str]
53-
(assert (and (= -1 (.indexOf str "="))
54-
(= -1 (.indexOf str "\u0000"))))
55-
(let [^java.lang.reflect.Constructor init
56-
(first (.getDeclaredConstructors java.lang.ProcessEnvironment$Variable))]
57-
(.setAccessible init true)
58-
(.newInstance init (into-array Object ["XXX" (.getBytes "XXX")]))))
59-
60-
(defn setenv
61-
([env]
62-
(run! (fn [[k v]] (setenv k v)) env))
63-
([^String var ^String val]
64-
;; This one is used by ProcessBuilder
65-
(.put theEnvironment (new-variable var) (new-value val))
66-
;; This one is used by System/getenv
67-
(.put theUnmodifiableEnvironment var val)
68-
;; Also change the actual OS environment for the process
69-
(.setenv posix var val 1)))
2+
(:require [lambdaisland.dotenv :as dotenv])
3+
(:import (java.nio.file Files LinkOption Path)))
704

715
(defn exists?
726
"Does the given path exist."
737
[path]
74-
(Files/exists (watch-deps/path path) (into-array LinkOption [])))
8+
(Files/exists path (into-array LinkOption [])))
9+
10+
(defn ->path
11+
[^String path]
12+
(Path/of path (into-array String [])))
13+
14+
(defn canonical-path
15+
[^Path path]
16+
(.toRealPath path (into-array LinkOption [])))
17+
18+
(def ^{:doc "The list of dotenv paths to watch.
7519
76-
(defn dotenv-watch-handler [paths]
77-
(let [paths (map #(Path/of % (into-array String [])) paths)]
78-
(fn [_]
79-
(setenv
80-
(apply merge
81-
(map #(when (exists? %)
82-
(dotenv/parse-dotenv (Files/readString %)))
83-
paths))))))
20+
Order counts as we use merge semantics when reading them in."}
21+
watch-paths [".env" ".env.local"])
8422

85-
(defn watch-handlers []
86-
(let [h (dotenv-watch-handler [".env" ".env.local"])]
87-
{".env" h
88-
".env.local" h}))
23+
(defn parse-dotenv
24+
[^Path path]
25+
(when (.exists (.toFile path))
26+
(-> (Files/readString path)
27+
(dotenv/parse-dotenv))))
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
(ns lambdaisland.launchpad.env.hacks
2+
"Make environment variables modifiable from within Java.
3+
4+
This is *very* dirty, it uses reflection to get at various private bits of
5+
Java, it relies on implementation details of OpenJDK, and it requires breaking
6+
module isolation (the process has to start with
7+
`--add-opens=java.base/java.lang=ALL-UNNAMED`
8+
`--add-opens=java.base/java.util=ALL-UNNAMED`). We also rely on jnr-posix to
9+
get to the underlying setenv system call, for good measure.
10+
11+
But hey it works!"
12+
(:require [lambdaisland.classpath.watch-deps :as watch-deps])
13+
(:import (java.nio.file Path)))
14+
15+
(set! *warn-on-reflection* true)
16+
17+
(defn accessible-field ^java.lang.reflect.Field [^Class klz field]
18+
(doto (.getDeclaredField klz field)
19+
(.setAccessible true)))
20+
21+
(defn get-static [field]
22+
(let [klz (Class/forName (namespace field))]
23+
(.get (accessible-field klz
24+
(name field)) klz)))
25+
26+
(defn get-field [^Object instance field]
27+
(.get (accessible-field (.getClass instance) (str field)) instance))
28+
29+
(defn set-field! [klz field obj val]
30+
(.set (accessible-field klz field) obj val))
31+
32+
(defn set-static! [klz field val]
33+
(set-field! klz field klz val))
34+
35+
(def ^java.util.Map theEnvironment
36+
(get-static 'java.lang.ProcessEnvironment/theEnvironment))
37+
38+
(def ^java.lang.ProcessEnvironment$StringEnvironment theUnmodifiableEnvironment
39+
(get-field (get-static 'java.lang.ProcessEnvironment/theUnmodifiableEnvironment) 'm))
40+
41+
(def ^jnr.posix.POSIX posix (jnr.posix.POSIXFactory/getPOSIX))
42+
43+
(defn new-value [^String str]
44+
(assert (= -1 (.indexOf str "\u0000")))
45+
(let [^java.lang.reflect.Constructor init
46+
(first (.getDeclaredConstructors java.lang.ProcessEnvironment$Value))]
47+
(.setAccessible init true)
48+
(.newInstance init (into-array Object ["XXX" (.getBytes "XXX")]))))
49+
50+
(defn new-variable [^String str]
51+
(assert (and (= -1 (.indexOf str "="))
52+
(= -1 (.indexOf str "\u0000"))))
53+
(let [^java.lang.reflect.Constructor init
54+
(first (.getDeclaredConstructors java.lang.ProcessEnvironment$Variable))]
55+
(.setAccessible init true)
56+
(.newInstance init (into-array Object ["XXX" (.getBytes "XXX")]))))
57+
58+
(defn setenv
59+
([env]
60+
(run! (fn [[k v]] (setenv k v)) env))
61+
([^String var ^String val]
62+
;; This one is used by ProcessBuilder
63+
(.put theEnvironment (new-variable var) (new-value val))
64+
;; This one is used by System/getenv
65+
(.put theUnmodifiableEnvironment var val)
66+
;; Also change the actual OS environment for the process
67+
(.setenv posix var val 1)))
68+
69+
(defn dotenv-watch-handler
70+
[{:keys [watch-paths parse-fn]}]
71+
(let [parse-fn (requiring-resolve parse-fn)]
72+
(fn [e]
73+
(let [{:keys [type _]} e]
74+
(when (contains? #{:modify :create :delete} type)
75+
(doseq [path watch-paths]
76+
(println "[watch-dotenv] ✨ Reloading"
77+
(str (.relativize ^Path (watch-deps/canonical-path ".") path))
78+
""))
79+
;; We always reload everything so that we can preserve merge semantics.
80+
(setenv (apply merge (map parse-fn watch-paths))))))))
81+
82+
(defn watch-handlers [opts]
83+
(let [{:keys [watch-paths]} opts
84+
handler (dotenv-watch-handler opts)]
85+
(into {}
86+
(map (fn [p]
87+
[(str p) handler]))
88+
watch-paths)))

0 commit comments

Comments
 (0)