deps phase 2: jolt-deps tool resolves deps.edn git/local deps

A separate jolt-deps executable (mirroring jpm beside janet) resolves a
deps.edn into source roots and runs jolt with them on JOLT_PATH; the runtime
stays deps-agnostic.

- src/jolt/deps.janet: read deps.edn (Janet parses it directly), resolve
  :git/* and :local/root via jpm/pm's download-bundle (git fetch + cache, no
  build), recurse for transitive deps, return de-duped roots. jpm loaded
  lazily so it's never embedded. Roots cached on a deps.edn hash.
- src/jolt/deps_cli.janet + project.janet: the jolt-deps tool — path/run/repl/-e.
- main: apply JOLT_PATH at runtime (the CLI ctx is frozen into the image at
  build, so init's env read wouldn't see it).

Verified end to end against a local dep and a real git dep (medley). git only,
pure clj/cljc. Test: deps-resolve-test (local deps, no network).
This commit is contained in:
Yogthos 2026-06-05 23:05:47 -04:00
parent 6ea43fb623
commit 4d8494d620
6 changed files with 234 additions and 14 deletions

88
src/jolt/deps.janet Normal file
View file

@ -0,0 +1,88 @@
# deps.edn resolution for Jolt.
#
# Resolve git and :local/root dependencies from a deps.edn into a list of source
# directories, which the loader then searches (see evaluator/find-ns-file). We
# reuse jpm's git fetch + cache (jpm/pm) rather than shipping a package manager.
# Maven (:mvn/version) deps are ignored — git only, pure clj/cljc only.
#
# jpm is loaded lazily (require, not import) so it's needed only at resolve time
# (dev/build), never embedded in the shipped binary.
# A typical deps.edn is also valid Janet data, so we read it with Janet's parser.
# (EDN-only forms — #{} sets, tagged literals, namespaced maps — aren't handled;
# deps.edn rarely uses them in :deps/:paths.)
(defn read-edn [path]
(when (os/stat path)
(try (parse (slurp path)) ([_] nil))))
(defn- jpm-fn [mod sym]
(get-in (require mod) [sym :value]))
(defn- ensure-jpm-config [tree]
((jpm-fn "jpm/config" 'load-default))
(setdyn :modpath tree)
(setdyn :gitpath (dyn :gitpath "git")))
(defn- clone-git [spec]
# spec is a deps.edn dep value: {:git/url ... :git/sha/:git/tag ...}
(def resolve-bundle (jpm-fn "jpm/pm" 'resolve-bundle))
(def download-bundle (jpm-fn "jpm/pm" 'download-bundle))
(def b (resolve-bundle {:url (get spec :git/url)
:sha (get spec :git/sha)
:tag (get spec :git/tag)
:shallow false}))
(download-bundle (b :url) (b :type) (b :tag) (b :shallow)))
(defn- src-roots
"Source dirs of a project/dep at `dir`: its deps.edn :paths joined to dir
(default [\"src\"])."
[dir edn]
(map |(string dir "/" $) (or (and edn (get edn :paths)) ["src"])))
(defn resolve-deps
"Resolve the git/:local deps of `deps-edn-path` into an ordered, de-duplicated
array of source dirs (the project's own :paths first, then each dependency's,
transitively). `tree` is where jpm's clone cache lives (default ./jpm_tree)."
[deps-edn-path &opt tree]
(default tree (string (os/cwd) "/jpm_tree"))
(os/mkdir tree)
(ensure-jpm-config tree)
(def roots @[])
(def seen @{})
(defn add-root [r] (unless (index-of r roots) (array/push roots r)))
(defn process [edn base-dir own-paths?]
(when (dictionary? edn)
(when own-paths? (each r (src-roots base-dir edn) (add-root r)))
(eachp [lib spec] (or (get edn :deps) {})
(def k (string lib))
(unless (get seen k)
(put seen k true)
(def dir
(cond
(and (dictionary? spec) (get spec :git/url)) (clone-git spec)
(and (dictionary? spec) (get spec :local/root))
(let [lr (get spec :local/root)]
(if (string/has-prefix? "/" lr) lr (string base-dir "/" lr)))
nil)) # :mvn/* and anything else: skip
(when dir
(def dep-edn (read-edn (string dir "/deps.edn")))
(each r (src-roots dir dep-edn) (add-root r))
(process dep-edn dir false))))))
(process (read-edn deps-edn-path) (os/cwd) true)
roots)
(defn resolve-deps-cached
"Like resolve-deps, but caches the resolved roots in the tree keyed on a hash
of the deps.edn, so an unchanged deps.edn resolves without re-fetching."
[deps-edn-path &opt tree]
(default tree (string (os/cwd) "/jpm_tree"))
(when (os/stat deps-edn-path)
(os/mkdir tree)
(def cache-file (string tree "/.jolt-deps-roots.jdn"))
(def h (hash (slurp deps-edn-path)))
(def cached (when (os/stat cache-file) (try (parse (slurp cache-file)) ([_] nil))))
(if (and cached (= h (get cached :hash)))
(get cached :roots)
(let [roots (resolve-deps deps-edn-path tree)]
(spit cache-file (string/format "%j" {:hash h :roots roots}))
roots))))

41
src/jolt/deps_cli.janet Normal file
View file

@ -0,0 +1,41 @@
# jolt-deps — a separate tool that resolves a deps.edn into Jolt source roots.
#
# Mirrors how jpm is a tool beside the janet runtime: the jolt runtime knows
# nothing about deps.edn — it just searches the roots in JOLT_PATH (see
# api/init). This tool does the resolution (git + :local deps, via jpm's fetch
# cache) and either prints the roots or launches jolt with JOLT_PATH set.
#
# jolt-deps path print the resolved roots (':'-joined), e.g. for
# JOLT_PATH=$(jolt-deps path) jolt file.clj
# jolt-deps run FILE [args] resolve, then run `jolt FILE args` with JOLT_PATH set
# jolt-deps repl resolve, then start a jolt REPL with JOLT_PATH set
# jolt-deps -e EXPR [args] resolve, then `jolt -e EXPR ...` with JOLT_PATH set
#
# The jolt binary is found via $JOLT_BIN, else `jolt` on PATH.
(import ./deps :as deps)
(defn- roots []
(if (os/stat "deps.edn") (deps/resolve-deps-cached "deps.edn") @[]))
(defn- exec-jolt [extra-args]
# Set JOLT_PATH in our own env and let the child inherit it (os/execute's env
# arg isn't honored here; inheriting is reliable).
(def rs (string/join (roots) ":"))
(def existing (os/getenv "JOLT_PATH"))
(os/setenv "JOLT_PATH" (if (and existing (> (length existing) 0)) (string rs ":" existing) rs))
(os/execute [(os/getenv "JOLT_BIN" "jolt") ;extra-args] :p))
(defn- usage []
(print "usage: jolt-deps [path | run FILE [args] | repl | -e EXPR [args]]"))
(defn main [&]
(def argv (tuple/slice (or (dyn :args) @[]) 1))
(def cmd (get argv 0))
(cond
(or (nil? cmd) (= cmd "help") (= cmd "-h") (= cmd "--help")) (usage)
(= cmd "path") (print (string/join (roots) ":"))
(= cmd "run") (os/exit (exec-jolt (tuple/slice argv 1)))
(= cmd "repl") (os/exit (exec-jolt []))
(= cmd "-e") (os/exit (exec-jolt argv))
(do (eprint "jolt-deps: unknown command " cmd) (usage) (os/exit 1))))

View file

@ -309,6 +309,12 @@
(def args (or (dyn :args) @[])) # @["jolt" arg1 arg2 ...]
(def argv (if (> (length args) 1) (array/slice args 1) @[]))
(ctx-set-current-ns ctx "user")
# JOLT_PATH must be applied at runtime: this `ctx` is built into the image at
# build time, so its source-paths can't capture the runtime environment.
# `jolt-deps` sets JOLT_PATH to the resolved deps.edn source roots.
(when-let [jp (os/getenv "JOLT_PATH")]
(each p (string/split ":" jp)
(when (> (length p) 0) (array/push (get (ctx :env) :source-paths) p))))
(cond
(empty? argv) (run-repl)
(or (= (argv 0) "-h") (= (argv 0) "--help")) (print-help)