Compare commits

...

5 Commits

Author SHA1 Message Date
John Swanson
df8499a664 ask whether to reuse h2 2026-02-03 08:56:18 -08:00
John Swanson
c33ed0162f better h2 querying 2026-02-03 08:35:56 -08:00
John Swanson
33b745bf37 Nicer output 2026-02-03 07:26:20 -08:00
John Swanson
59c2c7e0dd improved isolation for ./bin/mage dev-repl 2026-02-03 06:07:00 -08:00
John Swanson
b57d117c7e ./bin/mage dev-repl
This launches a metabase dev repl.

Configurable:
- database type
- database edition
- backend port
- config file
- token
- metabase edition (oss vs ee)

You can just run `./bin/mage dev-repl` and get an interactive CLI that
will ask you about these settings, launch the database, start the
frontend (on a random port), and start Metabase configured to talk to
that database/frontend.

Or you can specify options from the command line, like
`./bin/mage dev-repl -e ee -t all-features -d postgres`.
2026-02-02 13:08:42 -08:00
6 changed files with 545 additions and 15 deletions

21
bb.edn
View File

@@ -139,6 +139,27 @@
(let [[db version] (:arguments parsed)]
(start-db/start-db (:db-info (current-task)) db version)))}
dev-repl
{:doc "Launch Metabase dev environment (frontend + backend) with interactive configuration"
:examples [["./bin/mage dev-repl" "Interactive TUI to configure and launch"]
["./bin/mage dev-repl -e ee -t all-features -d postgres"
"Launch with specific config (skip prompts)"]
["./bin/mage dev-repl --no-frontend" "Backend only (skip frontend dev server)"]
["./bin/mage dev-repl --fresh" "Recreate database container from scratch"]]
:requires [[mage.dev-repl :as dev-repl]]
:options [["-e" "--edition EDITION" "ee or oss"]
["-t" "--token TOKEN" "Token type: all-features, starter-cloud, pro-cloud, pro-self-hosted, none"]
["-d" "--db DB" "Database: postgres, mysql, mariadb, h2"]
["-c" "--config FILE" "Config file path (default: dev/config.yml)"]
["-v" "--db-version VERSION" "Database version: latest or oldest (default: latest)"]
["-p" "--port PORT" "Backend port (default: 3000)" :parse-fn Integer/parseInt]
["-f" "--fresh" "Recreate database container (destroy existing data)"]
["-F" "--no-frontend" "Skip starting the frontend dev server"]]
:db-info {:postgres {:ports {:oldest 5432 :latest 5433} :eol-url "https://endoflife.date/api/postgres.json"}
:mysql {:ports {:oldest 3308 :latest 3309} :eol-url "https://endoflife.date/api/mysql.json"}
:mariadb {:ports {:oldest 3306 :latest 3307} :eol-url "https://endoflife.date/api/mariadb.json"}}
:task (task! (dev-repl/dev-repl parsed (select-keys (current-task) [:db-info])))}
nrepl
{:doc "Starts the babashka nrepl: helpful for mage development"
:requires [[babashka.nrepl.server :as nrepl.server]

View File

@@ -827,6 +827,12 @@
:exec-fn dev.h2/shell
:jvm-opts ["-Dfile.encoding=UTF-8"]}
;; connect to an H2 TCP listener:
;; clojure -M:connect-to-h2-tcp --port 9092 --db-file /path/to/metabase
:connect-to-h2-tcp
{:extra-paths ["dev/src"]
:main-opts ["-m" "dev.h2"]}
:generate-automagic-dashboards-pot
{:main-opts ["-m" "metabase.xrays.automagic-dashboards.dashboard-templates"]}

View File

@@ -1,5 +1,6 @@
(ns dev.h2
(:require
[clojure.tools.cli :as cli]
[environ.core :as env]
[metabase.app-db.data-source :as mdb.data-source]
[metabase.app-db.env :as mdb.env])
@@ -24,9 +25,51 @@
(def ^:private tcp-listener (atom nil))
(defn tcp-listen
"Starts a TCP server for the H2 app-db database on port 9092."
[]
(when @tcp-listener
(throw (ex-info "TCP listener is already running" {:port 9092})))
(reset! tcp-listener (.start (Server/createTcpServer (into-array ["-tcp" "-tcpAllowOthers" "-tcpPort" "9092"]))))
(println (str "H2 TCP server started (no username or password): jdbc:h2:tcp://localhost:9092/" (#'metabase.app-db.env/env->db-file metabase.app-db.env/env))))
"Starts a TCP server for the H2 app-db database on the specified port."
[port]
(when-not (= "h2" (:mb-db-type env/env))
(throw (ex-info "The database type is not H2" {:db-type (:mb-db-type env/env)})))
(when-let [listener @tcp-listener]
(throw (ex-info "TCP listener is already running" {:port (:port listener)})))
(let [server (.start (Server/createTcpServer (into-array ["-tcp" "-tcpAllowOthers" "-tcpPort" (str port)])))]
(reset! tcp-listener {:server server :port port})
(println (str "H2 TCP server started (no username or password): jdbc:h2:tcp://localhost:" port "/"
(#'metabase.app-db.env/env->db-file metabase.app-db.env/env)))))
(defn connect-tcp
"Open an H2 shell connected to a TCP listener."
[{:keys [port db-file]}]
(let [url (str "jdbc:h2:tcp://localhost:" port "/" db-file)]
(println "Connecting to database at URL" url)
(org.h2.tools.Shell/main (into-array String ["-url" url]))))
(def ^:private connect-cli-spec
[["-h" "--help" "Show this help text"]
["-p" "--port PORT" "H2 TCP listener port"
:parse-fn #(Integer/parseInt %)]
["-f" "--db-file PATH" "H2 database file path"]])
(defn -main
"CLI entry point for connecting to an H2 TCP listener."
[& args]
(let [{:keys [options errors summary]} (cli/parse-opts args connect-cli-spec)
{:keys [help port db-file]} options]
(when help
#_:clj-kondo/ignore
(do
(println "Usage: clojure -M:connect-to-h2-tcp --port PORT --db-file PATH")
(println "Options:")
(println summary))
(System/exit 0))
(when (seq errors)
#_:clj-kondo/ignore
(doseq [error errors]
(println error))
(System/exit 1))
(when-not (and port db-file)
#_:clj-kondo/ignore
(do
(println "Both --port and --db-file are required.")
(println "Usage: clojure -M:connect-to-h2-tcp --port PORT --db-file PATH"))
(System/exit 1))
(connect-tcp {:port port :db-file db-file})))

View File

@@ -64,6 +64,8 @@
(def cli-spec [["-h" "--help" "Show this help text"]
["-H" "--hot" "Enable hot reloading"] ;
[nil "--h2-tcp-port PORT" "Start an H2 TCP listener on the specified port"
:parse-fn #(Integer/parseInt %)]
["-p" "--port PORT" "Port to run the nREPL server on"
:default 50605
:parse-fn #(Integer/parseInt %)]])
@@ -75,9 +77,10 @@
Command Line Args:
`--hot` - Checks for modified files and reloads them during a request."
`--hot` - Checks for modified files and reloads them during a request.
`--h2-tcp-port` - Starts an H2 TCP listener on the specified port."
[& args]
(let [{:keys [help hot port]} (:options (cli/parse-opts args cli-spec))]
(let [{:keys [help hot h2-tcp-port port]} (:options (cli/parse-opts args cli-spec))]
(when help
#_:clj-kondo/ignore
(do
@@ -89,6 +92,8 @@
#_:clj-kondo/ignore
(println "Enabling hot reloading of code. Backend code will reload on every request.")
(alter-var-root #'*enable-hot-reload* (constantly true)))
(when h2-tcp-port
((requiring-resolve 'dev.h2/tcp-listen) h2-tcp-port))
(future
#_:clj-kondo/ignore
(println "Starting Metabase cider repl on port" port)

445
mage/src/mage/dev_repl.clj Normal file
View File

@@ -0,0 +1,445 @@
(ns mage.dev-repl
(:require
[babashka.fs :as fs]
[babashka.http-client :as http]
[babashka.process :as p]
[clojure.string :as str]
[mage.color :as c]
[mage.shell :as shell]
[mage.util :as u])
(:import
(java.net ServerSocket)))
(set! *warn-on-reflection* true)
(defn- find-free-port
"Finds a free port"
[]
(with-open [socket (ServerSocket. 0)]
(.getLocalPort socket)))
(defn- port-in-use?
"Returns true if the port cannot be bound."
[port]
(try
(with-open [_socket (ServerSocket. port)]
false)
(catch Exception _ true)))
(def ^:private backend-port-options [3000 3001 3002 3003 3004])
(defn- format-port-option [port]
(str port (when (port-in-use? port) " [in use]")))
(defn- parse-port [s]
(when-let [m (re-find #"\d+" (str s))]
(Integer/parseInt m)))
;; Worktree / checkout helpers
(defn- worktree-id
[]
(let [path (.getCanonicalPath (java.io.File. u/project-root-directory))
digest (java.security.MessageDigest/getInstance "SHA-1")
hex (->> (.digest digest (.getBytes path "UTF-8"))
(map #(format "%02x" (bit-and % 0xff)))
(apply str))]
(subs hex 0 8)))
(defn- h2-db-base-path [worktree-id]
(str "/tmp/metabase-h2/" worktree-id "/metabase"))
(defn- ensure-h2-dir! [base-path]
(fs/create-dirs (fs/parent base-path)))
(defn- delete-h2-files! [base-path]
(doseq [suffix ["" ".mv.db" ".trace.db" ".h2.db" ".lock.db"]]
(fs/delete-if-exists (str base-path suffix))))
(defn- h2-db-exists?
"Returns true when any H2 file exists for the base path."
[base-path]
(boolean
(some #(fs/exists? (str base-path %))
["" ".mv.db" ".trace.db" ".h2.db" ".lock.db"])))
;; H2 reuse prompt
(defn- ask-reuse-h2?
"Ask user whether to reuse existing H2 files."
[base-path]
(println (c/yellow "Found existing H2 database:") base-path)
(let [choice (u/fzf-select! ["Reuse existing" "Create fresh"]
"--height=10 --layout=reverse --prompt='H2 database: '")]
(= choice "Reuse existing")))
;; Token env var mapping (expects MBDEV_*_TOKEN in env)
(def token-env-vars
{:all-features "MBDEV_ALL_FEATURES_TOKEN"
:starter-cloud "MBDEV_STARTER_CLOUD_TOKEN"
:pro-cloud "MBDEV_PRO_CLOUD_TOKEN"
:pro-self-hosted "MBDEV_PRO_SELF_HOSTED_TOKEN"})
;; Container management with dynamic ports and reuse
(defn- container-name [worktree-id db-type version]
(format "mb-dev-%s-%s-%s" worktree-id (name db-type) (name version)))
(defn- container-internal-port [db-type]
(case db-type
:postgres 5432
(:mysql :mariadb) 3306))
(defn- docker-image [db-type version]
(str (name db-type) ":" (name version)))
(defn- docker-env-vars [db-type]
(case db-type
:postgres ["-e" "POSTGRES_USER=metabase"
"-e" "POSTGRES_PASSWORD=password"
"-e" "POSTGRES_DB=metabase"]
(:mysql :mariadb) ["-e" "MYSQL_DATABASE=metabase_test"
"-e" "MYSQL_ALLOW_EMPTY_PASSWORD=yes"]))
(defn- db-shell-command
"Return a shell command to connect to the DB, or nil if unavailable."
[db-type db-info h2-path h2-tcp-port]
(case db-type
:postgres (when-let [name (:name db-info)]
(format "docker exec -it %s psql -U metabase -d metabase" name))
:mysql (when-let [name (:name db-info)]
(format "docker exec -it %s mysql -uroot metabase_test" name))
:mariadb (when-let [name (:name db-info)]
(format "docker exec -it %s mariadb -uroot metabase_test" name))
:h2 (when (and h2-path h2-tcp-port)
(format "clojure -M:dev:connect-to-h2-tcp --port %s --db-file %s" h2-tcp-port h2-path))
nil))
(defn- label-width
"Compute max label width (including trailing colon) plus one space for alignment."
[labels]
(inc (apply max (map #(count (str % ":")) labels))))
(defn- format-section-line
"Format a section line with aligned label/value and optional coloring."
[label value width label-color value-color]
(let [label-text (str label ":")
pad-count (max 0 (- width (count label-text)))
padding (apply str (repeat pad-count " "))
label-str (if label-color (label-color label-text) label-text)
value-str (if value-color (value-color (str value)) (str value))]
(str " " label-str padding value-str)))
(defn- container-running? [name]
(let [output (u/sh "docker" "ps" "-q" "--filter" (str "name=^" name "$"))]
(not (str/blank? output))))
(defn- container-exists? [name]
(let [output (u/sh "docker" "ps" "-aq" "--filter" (str "name=^" name "$"))]
(not (str/blank? output))))
(defn- get-container-port
"Get the host port for a running container."
[name internal-port]
(let [output (u/sh "docker" "port" name (str internal-port))]
(when-let [[_ port] (re-find #":(\d+)$" output)]
(Integer/parseInt port))))
(defn- create-db-container!
"Create a new DB container with a random port. Returns the host port."
[db-type version name]
(let [internal-port (container-internal-port db-type)
image (docker-image db-type version)
env-vars (docker-env-vars db-type)]
(println (c/green "Creating database container:") name)
(println (c/cyan " Image:") image)
(apply shell/sh* "docker" "run" "-d"
"--name" name
"-p" (str "0:" internal-port) ; 0 = random port
(concat env-vars [image]))
;; Wait a moment for container to start
(Thread/sleep 1000)
(let [port (get-container-port name internal-port)]
(println (c/cyan " Port:") port)
port)))
(defn- ask-reuse-container?
"Ask user whether to reuse existing container."
[name running?]
(println (c/yellow "Found existing container:") name
(if running? "(running)" "(stopped)"))
(let [choice (u/fzf-select! ["Reuse existing" "Create fresh"]
"--height=10 --layout=reverse --prompt='Database container: '")]
(= choice "Reuse existing")))
(defn- ensure-db!
"Ensure DB container is running. Returns {:port :stop-on-exit? :name}.
- If fresh?, remove existing and create new with random port
- If container exists, ask user whether to reuse
- If missing, create new with random port
:stop-on-exit? is true if we created or started the container (so we should stop it on exit)"
[worktree-id db-type db-version fresh?]
(let [name (container-name worktree-id db-type db-version)
internal-port (container-internal-port db-type)
running? (container-running? name)
exists? (or running? (container-exists? name))]
(cond
;; --fresh flag: always create new
fresh?
(do
(println (c/yellow "Fresh database requested, creating new container..."))
(when exists?
(shell/sh* {:quiet? true} "docker" "rm" "-f" name))
{:port (create-db-container! db-type db-version name)
:stop-on-exit? true
:name name})
;; Container exists: ask user whether to reuse
(and exists? (ask-reuse-container? name running?))
(do
(when-not running?
(println (c/green "Starting stopped container:") name)
(shell/sh* "docker" "start" name)
(Thread/sleep 1000))
{:port (get-container-port name internal-port)
:stop-on-exit? (not running?) ; stop if we started it, leave alone if was already running
:name name})
;; User chose fresh, or no container exists
:else
(do
(when exists?
(println (c/yellow "Removing existing container..."))
(shell/sh* {:quiet? true} "docker" "rm" "-f" name))
{:port (create-db-container! db-type db-version name)
:stop-on-exit? true
:name name}))))
(defn- stop-container! [name]
(println)
(println (c/yellow "Stopping database container:") name)
(shell/sh* {:quiet? true} "docker" "stop" name))
(defn- wait-for-backend!
"Poll the health endpoint until the backend is ready. Prints progress dots."
[port]
(let [url (str "http://localhost:" port "/api/health")]
(loop [attempts 0]
(Thread/sleep 2000)
(let [healthy? (try
(let [resp (http/get url {:throw false :timeout 2000})]
(and (= 200 (:status resp))
(str/includes? (:body resp) "\"status\":\"ok\"")))
(catch Exception _ false))]
(if healthy?
(println (c/green " healthy!"))
(do
(print ".")
(flush)
(when (< attempts 120) ; ~4 minutes max
(recur (inc attempts)))))))))
;; Token validation
(defn- validate-token! [token]
(when (and token (not= token :none))
(let [env-var (get token-env-vars token)
value (System/getenv env-var)]
(when (str/blank? value)
(println (c/red "WARNING:") (c/yellow env-var) "is not set!")
(println "Set this env var with your" (name token) "token, or use --token none")
(u/exit 1)))))
(def ^:private fzf-opts "--height=10 --layout=reverse")
;; FZF prompts (or use CLI args)
(defn- select-edition [opts]
(or (some-> (:edition opts) keyword)
(keyword (u/fzf-select! ["ee" "oss"] (str fzf-opts " --prompt='Edition: '")))))
(defn- select-token [opts]
(or (some-> (:token opts) keyword)
(keyword (u/fzf-select!
["all-features" "starter-cloud" "pro-cloud" "pro-self-hosted" "none"]
(str fzf-opts " --prompt='Token: '")))))
(defn- select-db [opts]
(or (some-> (:db opts) keyword)
(keyword (u/fzf-select! ["postgres" "mysql" "mariadb" "h2"] (str fzf-opts " --prompt='Database: '")))))
(defn- select-config [opts]
(or (:config opts)
(let [files (u/shl "find" "dev" "-name" "*.yml" "-o" "-name" "*.yaml")
selected (if (seq files)
(u/fzf-select! (conj files "none") (str fzf-opts " --prompt='Config file: '"))
(u/fzf-select! ["dev/config.yml" "none"] (str fzf-opts " --prompt='Config file: '")))]
(when-not (= selected "none")
selected))))
(defn- select-port [opts]
(or (:port opts)
(let [options (mapv format-port-option backend-port-options)
;; become() replaces fzf with shell cmd - echo selection if exists, else query
input (u/fzf-select! options
(str fzf-opts " --prompt='Backend port: '"
" --bind 'enter:become([ -n {} ] && echo {} || echo {q})'"))]
(if-let [port (parse-port (str/trim input))]
port
(do
(println (c/red "Invalid port selection:") input)
(u/exit 1))))))
;; Build env map
(defn- build-env [edition token config-file db-type db-port frontend-port backend-port h2-path]
(cond-> {"MB_EDITION" (name edition)
"MB_ENABLE_TEST_ENDPOINTS" "true"
"MB_DANGEROUS_UNSAFE_ENABLE_TESTING_H2_CONNECTIONS_DO_NOT_ENABLE" "true"}
;; Backend port
backend-port
(assoc "MB_JETTY_PORT" (str backend-port))
;; Frontend port
frontend-port
(assoc "MB_FRONTEND_DEV_PORT" (str frontend-port))
;; EE token
(and (= edition :ee) (not= token :none))
(assoc "MB_PREMIUM_EMBEDDING_TOKEN" (System/getenv (get token-env-vars token))
"METASTORE_DEV_SERVER_URL" "https://token-check.staging.metabase.com"
"MB_CONFIG_FILE_PATH" config-file)
;; Database (non-H2)
(not= db-type :h2)
(assoc "MB_DB_TYPE" (if (= db-type :mariadb) "mysql" (name db-type))
"MB_DB_CONNECTION_URI"
(case db-type
:postgres (format "postgresql://metabase:password@localhost:%s/metabase" db-port)
(:mysql :mariadb) (format "mysql://root@localhost:%s/metabase_test" db-port)))
;; H2
(= db-type :h2)
(assoc "MB_DB_TYPE" "h2"
"MB_DB_FILE" h2-path)))
;; Frontend dev server
(defn- start-frontend!
"Start the frontend dev server in the background. Returns {:port :log-file}."
[edition port]
(let [log-file (str "/tmp/metabase-frontend-" port ".log")]
(let [log (java.io.File. log-file)]
(p/process {:dir u/project-root-directory
:out log
:err log
:extra-env {"MB_FRONTEND_DEV_PORT" (str port)
"MB_EDITION" (name edition)}}
"yarn" "build-hot"))
{:port port :log-file log-file}))
;; Build aliases
;; Uses :dev-start which runs user/-main - starts nREPL + Metabase
(defn- build-aliases [edition]
(str ":dev:dev-start:drivers:drivers-dev"
(when (= edition :ee) ":ee:ee-dev")))
;; Main entry point
(defn dev-repl [{:keys [options]} _task-opts]
(let [edition (select-edition options)
token (when (= edition :ee) (select-token options))
_ (validate-token! token)
config (when (and (= edition :ee) (not= token :none))
(select-config options))
db-type (select-db options)
db-version (keyword (or (:db-version options) "latest"))
fresh? (:fresh options)
no-frontend? (:no-frontend options)
backend-port (select-port options)
worktree-id (worktree-id)
_ (when (port-in-use? backend-port)
(println (c/yellow "WARNING:") "Port" backend-port "appears to be in use; startup may fail."))
;; Start frontend dev server (unless --no-frontend)
frontend (when-not no-frontend?
(start-frontend! edition (find-free-port)))
frontend-port (:port frontend)
frontend-log (:log-file frontend)
h2-path (when (= db-type :h2)
(h2-db-base-path worktree-id))
h2-tcp-port (when (= db-type :h2)
(find-free-port))
_ (when h2-path
(ensure-h2-dir! h2-path))
_ (when h2-path
(cond
fresh?
(delete-h2-files! h2-path)
(h2-db-exists? h2-path)
(when-not (ask-reuse-h2? h2-path)
(delete-h2-files! h2-path))))
;; Ensure DB container (reuse if exists, unless --fresh)
db-info (when (not= db-type :h2)
(ensure-db! worktree-id db-type db-version fresh?))
db-port (:port db-info)
env-map (build-env edition token config db-type db-port frontend-port backend-port h2-path)
aliases (build-aliases edition)
backend-log (str "/tmp/metabase-backend-" backend-port ".log")]
(let [nrepl-port (find-free-port)
cmd (cond-> ["clojure" (str "-M" aliases) "-p" (str nrepl-port)]
h2-tcp-port (conj "--h2-tcp-port" (str h2-tcp-port)))]
;; Show config
(let [info-width (label-width ["Edition" "Token" "Config" "Database" "Aliases"])
logs-width (label-width ["FE" "BE"])
conn-width (label-width ["nREPL" "DB"])
db-shell (db-shell-command db-type db-info h2-path h2-tcp-port)]
(println)
(println (c/bold "Starting Metabase Dev REPL (Press Ctrl-C to Stop):"))
(println (format-section-line "Edition" (name edition) info-width c/cyan nil))
(when token
(println (format-section-line "Token" (name token) info-width c/cyan nil)))
(when config
(println (format-section-line "Config" config info-width c/cyan nil)))
(println (format-section-line "Database" (name db-type) info-width c/cyan nil))
(println (format-section-line "Aliases" aliases info-width c/cyan nil))
(println)
(println (c/bold "Logs:"))
(when frontend-log
(println (format-section-line "FE" (str "tail -f " frontend-log) logs-width c/yellow c/yellow)))
(println (format-section-line "BE" (str "tail -f " backend-log) logs-width c/yellow c/yellow))
(println)
(println (c/bold "Connectivity:"))
(println (format-section-line "nREPL" (str nrepl-port) conn-width c/green c/green))
(when db-shell
(println (format-section-line "DB" db-shell conn-width c/green c/green)))
(println))
;; Launch backend (output to log file)
(let [log-file (java.io.File. backend-log)
proc (apply p/process {:dir u/project-root-directory
:out log-file
:err log-file
:extra-env env-map}
cmd)
cleanup! (fn []
(p/destroy-tree proc)
(when (:stop-on-exit? db-info)
(stop-container! (:name db-info))))]
;; Register SIGINT handler for Ctrl+C cleanup
(sun.misc.Signal/handle
(sun.misc.Signal. "INT")
(reify sun.misc.SignalHandler
(handle [_ _]
(cleanup!)
(System/exit 0))))
(print (c/bold (format (str (c/green "Starting Metabase server (" (c/red "http://localhost:%s") (c/green "), waiting for healthy status"))) backend-port)))
(print "...")
(flush)
;; Poll health endpoint in background
(future (wait-for-backend! backend-port))
;; Wait for backend process to exit
@proc
;; Normal exit cleanup
(cleanup!)))))

View File

@@ -216,6 +216,8 @@
:mage/error (str "You don't have " (c/yellow cmd) " installed. Please install it to use this task.")
:babashka/exit 1}))))
(declare exit)
(defn fzf-select!
"Uses fzf to offer interactive selections.
@@ -237,13 +239,21 @@
(println "Running: " (c/green "brew install fzf"))
(sh "brew install fzf"))
(throw e))))
(->> (shell
{:out :string :in (str/join "\n" coll)}
(str "fzf"
(when (seq fzf-opts) " ")
fzf-opts))
:out
str/trim))
(try
(->> (shell
{:out :string :in (str/join "\n" coll)}
(str "fzf"
(when (seq fzf-opts) " ")
fzf-opts))
:out
str/trim)
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)
exit-code (or (:babashka/exit data)
(get-in data [:proc :exit]))]
(if (= 130 exit-code)
(exit (c/yellow "Cancelled.") 130)
(throw e))))))
;; Timing functions, parallel to time function in metabase.util
(defn start-timer