Skip to content

Commit 95527da

Browse files
authored
Merge pull request #12 from arichiardi/improve-dotenv-watch
Correct dotenv watch semantics and refactor
2 parents 1803f71 + fcd420c commit 95527da

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
@@ -29,6 +29,16 @@
2929
## Fixed
3030

3131
- Add an `:nrepl.middleware/descriptor` to silence the warning
32+
- Correct dotenv watch semantics. No matter the initial state we fix the behavior to achieve:
33+
34+
| .env | .env.local | Values From |
35+
| ----- |----------- | ------------ |
36+
||| `.env.local` |
37+
||| `.env` |
38+
||| `.env.local` |
39+
||| latest state |
40+
41+
The last line means we currently do not keep track and therefore cannot remove already set environment variables.
3242

3343
# 0.36.159-alpha (2025-02-09 / eafa135)
3444

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)