--js-runtime CLI Flag Implementation PlanFor agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a global --js-runtime RUNTIME CLI flag that lets users select an alternative JS runtime (e.g. Bun) for node-family builds without editing shadow-cljs.edn.
Architecture: The flag is parsed as a keyword in the global CLI spec, lifted into the :config-merge vector in cli_actual.clj:main so it flows through the existing build/configure deep-merge path, and validated/warned in build.clj and shared.clj. The node-repl path already destructures :js-runtime from opts, so it works without changes.
Tech Stack: Clojure, clojure.spec, clojure.tools.cli
Spec: docs/superpowers/specs/2026-03-30-js-runtime-cli-flag-design.md
src/main/shadow/cljs/devtools/cli_opts.cljc — add --js-runtime to cli-specsrc/main/shadow/cljs/devtools/cli_actual.clj — lift :js-runtime into :config-mergesrc/main/shadow/build/config.clj — add :js-runtime spec + optional key to ::buildsrc/main/shadow/build.clj — call warning helper during configuresrc/main/shadow/build/targets/shared.clj — add warning helper; throw on unrecognized runtime valuesdoc/js-runtime.md — add CLI usage section, fix "Valid values" wordingdoc/js-runtime-architecture.md — update spec location descriptionsrc/test/shadow/cljs/devtools/cli_opts_test.clj (new) — CLI parsing testssrc/test/shadow/build/targets/shared_test.clj (new) — runtime helper testssrc/test/shadow/build/js_runtime_warning_test.clj (new) — warning behavior tests--js-runtime flagFiles:
Create: src/test/shadow/cljs/devtools/cli_opts_test.clj
Modify: src/main/shadow/cljs/devtools/cli_opts.cljc:57-84
[ ] Step 1: Write the failing test
Create src/test/shadow/cljs/devtools/cli_opts_test.clj:
(ns shadow.cljs.devtools.cli-opts-test
(:require
[clojure.test :refer (deftest is)]
[shadow.cljs.devtools.cli-opts :as opts]))
(deftest parse-js-runtime-flag
(let [{:keys [options errors]}
(opts/parse ["node-repl" "--js-runtime" "bun"])]
(is (nil? errors))
(is (= :bun (:js-runtime options))))
(let [{:keys [options errors]}
(opts/parse ["watch" "app" "--js-runtime" "node"])]
(is (nil? errors))
(is (= :node (:js-runtime options))))
(let [{:keys [options errors]}
(opts/parse ["node-repl"])]
(is (nil? errors))
(is (nil? (:js-runtime options)))))
Run: lein test :only shadow.cljs.devtools.cli-opts-test/parse-js-runtime-flag
Expected: FAIL — :js-runtime is not a recognized option, so errors will be non-nil or the key will be missing.
--js-runtime to cli-specIn src/main/shadow/cljs/devtools/cli_opts.cljc, add the following line to the cli-spec vector, after the --via line (line 83) and before the --help line (line 84):
[nil "--js-runtime RUNTIME" "use alternative JS runtime (e.g. bun) for node-family builds"
:parse-fn keyword]
Run: lein test :only shadow.cljs.devtools.cli-opts-test/parse-js-runtime-flag
Expected: PASS
git add src/test/shadow/cljs/devtools/cli_opts_test.clj src/main/shadow/cljs/devtools/cli_opts.cljc
git commit -m "feat: add --js-runtime flag to CLI option spec"
:js-runtime into :config-merge in cli_actual.cljFiles:
Modify: src/test/shadow/cljs/devtools/cli_opts_test.clj
Modify: src/main/shadow/cljs/devtools/cli_actual.clj:132-134
[ ] Step 1: Write the failing test
Add to src/test/shadow/cljs/devtools/cli_opts_test.clj:
(deftest js-runtime-lifted-into-config-merge
(let [{:keys [options]}
(opts/parse ["watch" "app" "--js-runtime" "bun"])]
(is (= :bun (:js-runtime options)))
(let [lifted (opts/lift-js-runtime options)]
;; :js-runtime is copied into :config-merge for build/configure
(is (= [{:js-runtime :bun}] (:config-merge lifted)))
;; :js-runtime is also kept as a top-level key for node-repl
(is (= :bun (:js-runtime lifted)))))
;; when no --js-runtime, config-merge is untouched
(let [{:keys [options]}
(opts/parse ["watch" "app"])]
(let [lifted (opts/lift-js-runtime options)]
(is (nil? (:config-merge lifted))))))
Run: lein test :only shadow.cljs.devtools.cli-opts-test/js-runtime-lifted-into-config-merge
Expected: FAIL — opts/lift-js-runtime does not exist yet.
lift-js-runtime in cli_opts.cljcAdd to src/main/shadow/cljs/devtools/cli_opts.cljc, after the conj-vec function (after line 55):
(defn lift-js-runtime
"When :js-runtime is present in options, copies it into :config-merge
so it flows through build/configure's deep-merge path. The key is kept
in options as well because node-repl reads it directly from opts."
[options]
(if-let [rt (:js-runtime options)]
(update options :config-merge conj-vec {:js-runtime rt})
options))
Run: lein test :only shadow.cljs.devtools.cli-opts-test/js-runtime-lifted-into-config-merge
Expected: PASS
Add to the test file to verify --js-runtime accumulates with existing --config-merge:
(deftest js-runtime-accumulates-with-config-merge
(let [{:keys [options]}
(opts/parse ["watch" "app"
"--config-merge" "{:devtools {:preloads [foo]}}"
"--js-runtime" "bun"])]
(let [lifted (opts/lift-js-runtime options)]
(is (= [{:devtools {:preloads ['foo]}} {:js-runtime :bun}]
(:config-merge lifted))))))
Run: lein test :only shadow.cljs.devtools.cli-opts-test/js-runtime-accumulates-with-config-merge
Expected: PASS (config-merge is a vector; conj-vec appends)
lift-js-runtime into cli_actual.clj:mainIn src/main/shadow/cljs/devtools/cli_actual.clj, modify the main function. Change lines 132-134 from:
(defn main [& args]
(let [{:keys [action builds options summary errors] :as opts}
(opts/parse args)
to:
(defn main [& args]
(let [{:keys [action builds summary errors] :as opts}
(opts/parse args)
options
(opts/lift-js-runtime (:options opts))
opts
(assoc opts :options options)
Add to src/test/shadow/cljs/devtools/cli_opts_test.clj a test that exercises
the full parse-then-lift pipeline as main would see it, verifying :js-runtime
is both in :config-merge (for build/configure) and retained as a top-level
key (for node-repl):
(deftest parse-then-lift-end-to-end
(testing "build action: :js-runtime lands in :config-merge"
(let [{:keys [options] :as parsed} (opts/parse ["watch" "app" "--js-runtime" "bun"])
lifted (opts/lift-js-runtime options)]
(is (= [{:js-runtime :bun}] (:config-merge lifted)))
;; verify the rewritten opts map matches what cli_actual.clj:main produces
(let [opts (assoc parsed :options lifted)]
(is (= [{:js-runtime :bun}] (get-in opts [:options :config-merge]))))))
(testing "node-repl: :js-runtime available both in opts and config-merge"
;; node-repl* reads :js-runtime directly from opts to build process argv,
;; AND it flows through config-merge via build/configure. Both must work.
(let [{:keys [options]} (opts/parse ["node-repl" "--js-runtime" "bun"])
lifted (opts/lift-js-runtime options)]
(is (= :bun (:js-runtime lifted)))
(is (= [{:js-runtime :bun}] (:config-merge lifted))))))
Run: lein test :only shadow.cljs.devtools.cli-opts-test
Expected: PASS
git add src/test/shadow/cljs/devtools/cli_opts_test.clj \
src/main/shadow/cljs/devtools/cli_opts.cljc \
src/main/shadow/cljs/devtools/cli_actual.clj
git commit -m "feat: lift --js-runtime into :config-merge for build actions"
:js-runtime to base build specFiles:
Modify: src/main/shadow/build/config.clj:24-30
[ ] Step 1: Run existing tests to establish baseline
Run: lein test
Expected: All existing tests pass.
:js-runtime spec and optional keyIn src/main/shadow/build/config.clj, add a spec for :js-runtime after the ::target spec (after line 8):
(s/def ::js-runtime keyword?)
Then modify the ::build spec (lines 24-30) to include it as optional:
(s/def ::build
(s/keys
:req-un
[::build-id
::target]
:opt-un
[::build-hooks
::js-runtime]))
Run: lein test
Expected: All existing tests still pass.
git add src/main/shadow/build/config.clj
git commit -m "feat: add :js-runtime as optional key in base build spec"
build/configureFiles:
src/test/shadow/build/js_runtime_warning_test.cljsrc/main/shadow/build.clj:3-24 (require) and src/main/shadow/build.clj:354-365The warning point is in build/configure, in an imperative block before the
build-state threading form. This means util/warn (which threads through build
state) is not usable here. The codebase convention for user-facing messages in
this context is println with a "shadow-cljs - " prefix, so we use that.
Create src/test/shadow/build/js_runtime_warning_test.clj:
(ns shadow.build.js-runtime-warning-test
(:require
[clojure.test :refer (deftest is testing)]
[shadow.build.targets.shared :as shared]))
(deftest node-family-target-test
(testing "node-family targets"
(is (true? (shared/node-family-target? {:target :node-script})))
(is (true? (shared/node-family-target? {:target :node-test}))))
(testing "non-node-family targets"
(is (false? (shared/node-family-target? {:target :browser})))
(is (false? (shared/node-family-target? {:target :esm})))
(is (false? (shared/node-family-target? {:target :react-native})))))
(deftest js-runtime-warning-printed-for-non-node-targets
(testing "warning is printed for browser target with :js-runtime"
(let [output (with-out-str
(shared/warn-if-js-runtime-ignored
{:target :browser :build-id :app :js-runtime :bun}))]
(is (.contains output "js-runtime"))
(is (.contains output "ignored"))))
(testing "no warning for node-script target with :js-runtime"
(let [output (with-out-str
(shared/warn-if-js-runtime-ignored
{:target :node-script :build-id :app :js-runtime :bun}))]
(is (= "" output))))
(testing "no warning when :js-runtime is absent"
(let [output (with-out-str
(shared/warn-if-js-runtime-ignored
{:target :browser :build-id :app}))]
(is (= "" output)))))
Run: lein test :only shadow.build.js-runtime-warning-test
Expected: FAIL — shared/warn-if-js-runtime-ignored does not exist yet.
warn-if-js-runtime-ignored in shared.cljIn src/main/shadow/build/targets/shared.clj, add after the managed-runtime? function (after line 310):
(defn warn-if-js-runtime-ignored
"Prints a warning when :js-runtime is set on a non-node-family build config."
[{:keys [build-id target js-runtime] :as build-config}]
(when (and (contains? build-config :js-runtime)
(not (node-family-target? build-config)))
(println
(format "shadow-cljs - warning: :js-runtime %s ignored for build %s, target %s is not a node-family target"
(name js-runtime) (name build-id) (name target)))))
Run: lein test :only shadow.build.js-runtime-warning-test
Expected: PASS
warn-if-js-runtime-ignored into build/configureIn src/main/shadow/build.clj, add to the :require block (around line 3):
[shadow.build.targets.shared :as shared]
Then add after line 359 (after the spec validation block, before the :source-paths check):
(shared/warn-if-js-runtime-ignored config)
Add to src/test/shadow/build/js_runtime_warning_test.clj to verify the
shadow.build namespace loads with its new shared require (catches broken
import):
(deftest shadow-build-loads-with-shared-require
;; This catches a broken require in shadow.build after adding shared.
;; A full build/configure integration test would require bootstrapping
;; the entire build system, which is disproportionate for one call site.
(require 'shadow.build)
(is (some? (find-ns 'shadow.build))))
Run: lein test
Expected: All tests pass.
git add src/test/shadow/build/js_runtime_warning_test.clj \
src/main/shadow/build/targets/shared.clj \
src/main/shadow/build.clj
git commit -m "feat: warn when :js-runtime is set on non-node-family builds"
shared.cljFiles:
Create: src/test/shadow/build/targets/shared_test.clj
Modify: src/main/shadow/build/targets/shared.clj:292-306
[ ] Step 1: Write the failing tests
Create src/test/shadow/build/targets/shared_test.clj:
(ns shadow.build.targets.shared-test
(:require
[clojure.test :refer (deftest is testing)]
[shadow.build.targets.shared :as shared]))
(deftest js-runtime-command-test
(testing "known runtimes"
(is (= "bun" (shared/js-runtime-command {:js-runtime :bun})))
(is (= "node" (shared/js-runtime-command {:js-runtime :node})))
(is (= "node" (shared/js-runtime-command {}))))
(testing "unknown runtime throws"
(is (thrown-with-msg? Exception #"unknown"
(shared/js-runtime-command {:js-runtime :deno})))))
(deftest js-runtime-stdin-argv-test
(testing "known runtimes"
(is (= ["bun" "run" "-"] (shared/js-runtime-stdin-argv {:js-runtime :bun})))
(is (= ["node"] (shared/js-runtime-stdin-argv {:js-runtime :node})))
(is (= ["node"] (shared/js-runtime-stdin-argv {}))))
(testing "unknown runtime throws"
(is (thrown-with-msg? Exception #"unknown"
(shared/js-runtime-stdin-argv {:js-runtime :deno})))))
(deftest js-runtime-file-argv-test
(testing "known runtimes"
(is (= ["bun" "run" "out/test.js"]
(shared/js-runtime-file-argv {:js-runtime :bun :output-to "out/test.js"})))
(is (= ["node" "out/test.js"]
(shared/js-runtime-file-argv {:js-runtime :node :output-to "out/test.js"})))
(is (= ["node" "out/test.js"]
(shared/js-runtime-file-argv {:output-to "out/test.js"}))))
(testing "unknown runtime throws"
(is (thrown-with-msg? Exception #"unknown"
(shared/js-runtime-file-argv {:js-runtime :deno :output-to "out/test.js"})))))
Run: lein test :only shadow.build.targets.shared-test
Expected: the "unknown runtime throws" assertions FAIL — current code silently falls back to Node.
case defaults to throwIn src/main/shadow/build/targets/shared.clj, modify the three helper functions (lines 292-306).
Change js-runtime-command from:
(defn js-runtime-command [build-config]
(case (js-runtime build-config)
:bun "bun"
"node"))
to:
(defn js-runtime-command [build-config]
(case (js-runtime build-config)
:node "node"
:bun "bun"
(throw (ex-info (str "unknown :js-runtime " (pr-str (js-runtime build-config)))
{:js-runtime (js-runtime build-config)}))))
Change js-runtime-stdin-argv from:
(defn js-runtime-stdin-argv [build-config]
(case (js-runtime build-config)
:bun ["bun" "run" "-"]
["node"]))
to:
(defn js-runtime-stdin-argv [build-config]
(case (js-runtime build-config)
:node ["node"]
:bun ["bun" "run" "-"]
(throw (ex-info (str "unknown :js-runtime " (pr-str (js-runtime build-config)))
{:js-runtime (js-runtime build-config)}))))
Change js-runtime-file-argv from:
(defn js-runtime-file-argv [{:keys [output-to] :as build-config}]
(let [output-path (.getPath (io/file output-to))]
(case (js-runtime build-config)
:bun ["bun" "run" output-path]
["node" output-path])))
to:
(defn js-runtime-file-argv [{:keys [output-to] :as build-config}]
(let [output-path (.getPath (io/file output-to))]
(case (js-runtime build-config)
:node ["node" output-path]
:bun ["bun" "run" output-path]
(throw (ex-info (str "unknown :js-runtime " (pr-str (js-runtime build-config)))
{:js-runtime (js-runtime build-config)})))))
Run: lein test :only shadow.build.targets.shared-test
Expected: PASS
Run: lein test
Expected: All tests pass.
git add src/test/shadow/build/targets/shared_test.clj \
src/main/shadow/build/targets/shared.clj
git commit -m "feat: throw on unrecognized :js-runtime values instead of silent fallback"
Files:
Modify: doc/js-runtime.md
[ ] Step 1: Add CLI usage section to the docs
In doc/js-runtime.md, add a new section after the "Standalone node-repl" section (after line 63). Insert:
### CLI usage
Pass `--js-runtime` to any CLI command:
```bash
# standalone REPL with Bun
shadow-cljs node-repl --js-runtime bun
# watch a build with Bun
shadow-cljs watch my-build --js-runtime bun
# compile/release with Bun
shadow-cljs compile my-build --js-runtime bun
shadow-cljs release my-build --js-runtime bun
The CLI flag overrides :js-runtime in shadow-cljs.edn. It only takes
effect for node-family targets (:node-script, :node-test); on other
targets it is ignored with a warning.
- [ ] **Step 2: Update the "Valid values" section**
In `doc/js-runtime.md`, replace the existing "Valid values" section (lines 69-71):
```markdown
## Valid values
`:js-runtime` accepts `:node` or `:bun`. Any other value will fail spec validation.
with:
## Valid values
`:js-runtime` accepts `:node` or `:bun`. Any other value will produce an error
at build time or when starting a REPL.
In doc/js-runtime-architecture.md, replace lines 41-42:
The spec `::js-runtime` is defined as `#{:node :bun}` in the same namespace
and included in both the `:node-script` and `:node-test` target specs.
with:
The spec `::js-runtime` is defined as `keyword?` in `shadow.build.config` and
included as an optional key in the base `::build` spec (accepted by all
targets). It is also included in the `:node-script` and `:node-test` target
specs. On non-node-family targets, it is accepted but ignored with a warning.
git add doc/js-runtime.md doc/js-runtime-architecture.md
git commit -m "docs: add CLI usage for --js-runtime flag, update architecture doc"
Run: lein test
Expected: All tests pass, no regressions.
Run: lein run -m shadow.cljs.devtools.cli-actual -- --help
Expected: --js-runtime RUNTIME appears in the options list with the description text.
If any fixups were needed, commit them.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |