diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 486e29f11..8b8b2da9e 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -22,6 +22,11 @@ jobs:
java-package: 'jdk+fx'
distribution: 'zulu'
+ - name: Install clojure tools
+ uses: DeLaGuardo/setup-clojure@13.2
+ with:
+ lein: 2.11.2
+
- name: Build OIE (signed)
if: github.ref == 'refs/heads/main'
working-directory: server
diff --git a/.sdkmanrc b/.sdkmanrc
index 4f6817d13..213cecc31 100644
--- a/.sdkmanrc
+++ b/.sdkmanrc
@@ -2,3 +2,4 @@
# Add key=value pairs of SDKs to use below
java=8.0.442.fx-zulu
ant=1.10.14
+leiningen=2.11.2
diff --git a/server/build.xml b/server/build.xml
index 96830a4ec..adbf7e72c 100644
--- a/server/build.xml
+++ b/server/build.xml
@@ -1036,7 +1036,10 @@
-
+
+
+
+
diff --git a/server/mirth-build.properties b/server/mirth-build.properties
index 411999332..7a6b34c1e 100644
--- a/server/mirth-build.properties
+++ b/server/mirth-build.properties
@@ -4,4 +4,6 @@ client=../client
webadmin=../webadmin
manager=../manager
cli=../command
+tools=../tools
+tools.launcher=${tools}/engine-launcher
version=4.5.2
diff --git a/server/mirth-build.xml b/server/mirth-build.xml
index 1b4976042..fd043b33f 100644
--- a/server/mirth-build.xml
+++ b/server/mirth-build.xml
@@ -1,4 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
@@ -120,7 +131,13 @@
-
+
+
+
+
+
+
+
@@ -185,6 +202,7 @@
+
diff --git a/tools/engine-launcher/.gitignore b/tools/engine-launcher/.gitignore
new file mode 100644
index 000000000..d956ab0a1
--- /dev/null
+++ b/tools/engine-launcher/.gitignore
@@ -0,0 +1,13 @@
+/target
+/classes
+/checkouts
+profiles.clj
+pom.xml
+pom.xml.asc
+*.jar
+*.class
+/.lein-*
+/.nrepl-port
+/.prepl-port
+.hgignore
+.hg/
diff --git a/tools/engine-launcher/README.md b/tools/engine-launcher/README.md
new file mode 100644
index 000000000..174c09104
--- /dev/null
+++ b/tools/engine-launcher/README.md
@@ -0,0 +1,96 @@
+# OIE Engine Launcher
+
+[](https://opensource.org/licenses/MPL-2.0)
+A native engine launcher written in Clojure for launching the OIE Server with advanced `.vmoptions` support.
+
+## Overview
+
+This project provides a small, standalone launcher (`engine.jar`) that acts as a wrapper around the OIE Server.
+
+Its primary purpose is to provide a flexible and powerful way to configure the Java Virtual Machine (JVM) arguments used to launch the target application, going beyond the capabilities of simple shell scripts. Configuration is managed via an `engine.vmoptions` file and associated files within a `conf/` directory, all located alongside the launcher JAR.
+
+## Features
+
+* Advanced JVM configuration via `engine.vmoptions` and included files.
+* Supports standard JVM options (`-Xmx`, `-Dproperty=value`, etc.).
+* Allows environment variable substitution (`${VAR_NAME}`) in options files.
+* Supports including options from other files (`-include-options`).
+* Allows specifying the Java executable path (`-java-cmd`).
+* Flexible classpath manipulation (`-classpath`, `-classpath/a`, `-classpath/p`).
+* Determines Java executable based on `-java-cmd`, `JAVA_HOME`, or system `PATH`.
+* Includes a shutdown hook to attempt graceful termination of the launched Java process.
+
+## Usage (End-User Instructions)
+
+1. **Obtain Release:**
+ This tool is provided with the full engine.
+
+2. **Customize JVM Options (Optional):**
+ The default configuration includes common settings. To add custom JVM options (e.g., increase memory with `-Xmx`, define system properties with `-Dprop=val`) or use advanced directives, edit the `conf/custom.vmoptions` file. See the **Configuration** section below for details on the file structure and available syntax.
+ * **Note:** If you are running on Java 8, you should comment out the line `-include-options conf/default_modules.vmoptions` within the main `engine.vmoptions` file.
+
+3. **Run the Launcher:**
+ Open your terminal or command prompt, navigate into the extracted directory, and run the launcher using `java -jar`:
+
+ ```bash
+ java -jar engine.jar
+ ```
+
+The launcher will read `engine.vmoptions` and its included files, determine the Java command, construct the final JVM arguments and classpath (prepending `mirth-server-launcher.jar`), and start the OIE Server. Output will appear in your console. Press `Ctrl+C` to initiate shutdown; the launcher will attempt to stop the Server process gracefully.
+
+## Building (Developer Instructions)
+
+### Prerequisites
+
+* **Git**
+* **Leiningen** (Version 2.x recommended): The build tool for Clojure projects. [Installation Instructions](https://leiningen.org/#install)
+* **Java Development Kit (JDK)** (Version 8 or higher recommended, compatible with Clojure 1.12+).
+
+### Steps
+
+1. **Clone the Repository:**
+ ```bash
+ git clone [https://github.com/OpenIntegrationEngine/engine.git](https://github.com/OpenIntegrationEngine/engine.git)
+ cd engine
+ ```
+
+2. **Install Dependencies:** (Leiningen handles this automatically on first command run)
+
+3. **Run Tests:**
+ ```bash
+ lein test
+ ```
+
+4. **Build the Executable Uberjar:**
+ ```bash
+ lein uberjar
+ ```
+ This command compiles the Clojure code and packages it, along with its dependencies (Clojure itself), into a single executable JAR file.
+
+5. **Locate the Artifact:**
+ The standalone launcher JAR will be created at: `target/engine.jar`
+
+## Configuration (`engine.vmoptions` Structure)
+
+The JVM configuration is managed through a set of files included by the main `engine.vmoptions`. **Users should place all customizations in `conf/custom.vmoptions`.**
+
+**Note: If you are launching the server with java 8, you must comment out the line to include `conf/default_modules.vmoptions` found in the main `engine.vmoptions` or the application will fail to start.**
+
+### Available Syntax in `conf/custom.vmoptions`
+
+You can use the following within `conf/custom.vmoptions`:
+
+* **Standard JVM Options:** e.g., `-Xmx4g`, `-XX:+UseG1GC`, `-Duser.timezone=UTC`
+* **Environment Variable Substitution:** `${VAR_NAME}` (e.g., `-include-options ${ENV_PATH}/custom.vmoptions`)
+* **Directives:**
+ * `-include-options `: Include yet another options file.
+ * `-java-cmd `: Specify the Java executable path.
+ * `-classpath `: Replace classpath segments added so far by other directives *within custom.vmoptions or its includes*.
+ * `-classpath/a `: Append to the classpath segments added by other directives.
+ * `-classpath/p `: Prepend to the classpath segments added by other directives.
+
+(Note: `mirth-server-launcher.jar` is always prepended to the final classpath after all options files are parsed).
+
+## License
+
+This project is licensed under the **Mozilla Public License Version 2.0**. See the [LICENSE](https://mozilla.org/MPL/2.0/) file or the license header in source files for details.
diff --git a/tools/engine-launcher/project.clj b/tools/engine-launcher/project.clj
new file mode 100644
index 000000000..d9997f418
--- /dev/null
+++ b/tools/engine-launcher/project.clj
@@ -0,0 +1,18 @@
+;;;
+;;; SPDX-FileCopyrightText: 2025 Tony Germano tony@germano.name
+;;;
+;;; SPDX-License-Identifier: MPL-2.0
+;;;
+
+(defproject org.openintegrationengine/engine "0.1.0-SNAPSHOT"
+ :description "A native engine for launching Java applications with advanced .vmoptions support"
+ :url "https://github.com/OpenIntegrationEngine/engine"
+ :license {:name "Mozilla Public License Version 2.0"
+ :url "https://mozilla.org/MPL/2.0/"}
+ :dependencies [[org.clojure/clojure "1.12.0"]]
+ :main org.openintegrationengine.engine.server.launcher
+ :aot :all
+
+ :uberjar-name "engine.jar"
+
+ :profiles {:dev {:resource-paths ["resources"]}})
diff --git a/tools/engine-launcher/src/org/openintegrationengine/engine/server/launcher.clj b/tools/engine-launcher/src/org/openintegrationengine/engine/server/launcher.clj
new file mode 100644
index 000000000..eda6d6371
--- /dev/null
+++ b/tools/engine-launcher/src/org/openintegrationengine/engine/server/launcher.clj
@@ -0,0 +1,371 @@
+;;;
+;;; SPDX-FileCopyrightText: 2025 Tony Germano tony@germano.name
+;;;
+;;; SPDX-License-Identifier: MPL-2.0
+;;;
+
+;;;;
+;; File: launcher.clj
+;; Purpose: Main entry point for the OIE Engine Launcher.
+;;
+;; This Clojure application acts as a sophisticated wrapper for launching a target Java application
+;; (specifically, OIE Server, as indicated by hardcoded values).
+;; Its primary responsibilities include:
+;; 1. Parsing a `.vmoptions` file (`engine.vmoptions` by default) to gather JVM arguments.
+;; 2. Supporting advanced features within the `.vmoptions` file, such as:
+;; - Environment variable substitution (e.g., `${VAR_NAME}`).
+;; - Including options from other files (`-include-options`).
+;; - Specifying the Java command path (`-java-cmd`).
+;; - Manipulating the classpath (`-classpath`, `-classpath/a`, `-classpath/p`).
+;; 3. Determining the appropriate Java executable to use (respecting `-java-cmd`, JAVA_HOME, or system PATH).
+;; 4. Constructing the final classpath, prepending the required JAR.
+;; 5. Launching the target Java application as a separate process using ProcessBuilder.
+;; 6. Managing the lifecycle of the child process, including a shutdown hook for graceful termination.
+;; 7. Propagating the exit code of the child process.
+;;
+;; The code is structured to separate pure logic (parsing, command building) into helper functions
+;; for testability, while the `-main` function orchestrates these helpers and handles side effects.
+;;;;
+(ns org.openintegrationengine.engine.server.launcher
+ (:require [clojure.string :as str]
+ [clojure.java.io :as io])
+ (:import [java.io File FileNotFoundException IOException])
+ ;; :gen-class allows this namespace to be compiled into a Java class
+ ;; with a static `main` method, making it usable as the project's entry point.
+ (:gen-class))
+
+;; =============================================
+;; Helper Functions (Pure Logic / Testable Core)
+;; =============================================
+;; This section contains functions responsible for the core logic of parsing,
+;; substitution, and command construction. They are designed to be pure or
+;; accept dependencies (like environment/file accessors) explicitly,
+;; making them easier to unit test without real side effects.
+
+(defn substitute-env-vars
+ "Substitutes `${VAR_NAME}` patterns within a given string `s`.
+ Uses the provided `getenv-fn` function to resolve environment variable values.
+ This decoupling allows for easy testing with mock environments.
+ Returns the string with substitutions applied; unresolved variables become empty strings. Pure."
+ [s getenv-fn] ; getenv-fn :: String -> String | nil
+ (str/replace s #"\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}"
+ (fn [[_ var-name]] (or (getenv-fn var-name) ""))))
+
+(defn- parse-vmoptions* ; Recursive internal implementation - see `parse-vmoptions` public API.
+ "Recursively parses a vmoptions file and any files included via `-include-options`.
+ Accumulates JVM options, classpath segments, the effective Java command path, and warnings.
+
+ Parameters:
+ - `file-path`: Path to the vmoptions file to parse.
+ - `current-classpath`: The classpath string accumulated so far.
+ - `current-java-cmd-path`: The java command path determined so far (last one wins).
+ - `config`: A map containing functions for interacting with the environment,
+ enabling testability via dependency injection. Expected keys:
+ :read-file-fn :: String -> String (throws FileNotFoundException)
+ :getenv-fn :: String -> String | nil
+ :is-file-fn :: String -> boolean
+ :path-separator :: String (e.g., \":\" or \";\")
+
+ Returns:
+ A map describing the parsing result:
+ { :ok? boolean ; Indicates if parsing (including includes) succeeded without file errors.
+ :options [String] ; List of processed JVM options.
+ :classpath String ; The fully constructed classpath string.
+ :java-cmd-path String | nil ; The path specified by the *last* encountered -java-cmd, or nil.
+ :warnings [String] ; List of non-fatal issues encountered (e.g., included file not found).
+ :error Keyword | nil ; Keyword indicating fatal error type (e.g., :file-not-found) if ok? is false.
+ :path String | nil ; Path associated with the fatal error, if any.
+ }
+
+ Note on state propagation: Classpath and java-cmd-path state flows *through* recursive calls.
+ The state *returned* from an include call becomes the *current* state for subsequent lines."
+ [file-path current-classpath current-java-cmd-path config]
+ ;; Destructure the configuration map for easier access to injected dependencies.
+ (let [{:keys [read-file-fn getenv-fn is-file-fn path-separator]} config]
+ (try
+ ;; Read the file content using the injected function.
+ (let [content (read-file-fn file-path)]
+ ;; Process lines using loop/recur for tail-call optimization.
+ (loop [lines (->> content
+ (str/split-lines) ; Split into lines
+ (map str/trim) ; Trim whitespace
+ (remove #(or (str/blank? %) (str/starts-with? % "#")))) ; Remove blank lines and comments
+ ;; Accumulators for parsing results:
+ options [] ; JVM options
+ classpath current-classpath ; Classpath string
+ java-cmd-path current-java-cmd-path ; Path to java executable
+ warnings []] ; Non-fatal warnings
+
+ (if (empty? lines)
+ ;; Base case: All lines processed, return accumulated results.
+ {:ok? true :options options :classpath classpath :java-cmd-path java-cmd-path :warnings warnings}
+
+ ;; Recursive step: Process the first line.
+ (let [line (first lines)
+ remaining-lines (rest lines)
+ trimmed-line (str/trim line) ; Ensure trimming even if original map didn't trim perfectly
+ ;; Define a local substitution function using the injected getenv-fn.
+ ;; This performs ${VAR} substitution on relevant parts of the line.
+ subst-fn (fn [s] (substitute-env-vars s getenv-fn))]
+
+ (cond
+ ;; --- Handle -include-options directive ---
+ (str/starts-with? trimmed-line "-include-options")
+ (let [included-path-str (str/trim (subs trimmed-line (count "-include-options")))]
+ ;; Check if the included path is a valid file using the injected function.
+ (if (is-file-fn included-path-str)
+ ;; Recursively parse the included file, passing down the *current* state.
+ (let [sub-result (parse-vmoptions* included-path-str classpath java-cmd-path config)]
+ (if (:ok? sub-result)
+ ;; Include succeeded: Continue parsing remaining lines, using the *updated* state
+ ;; (options, classpath, java-cmd-path, warnings) returned from the recursive call.
+ (recur remaining-lines
+ (concat options (:options sub-result)) ; Append options from included file
+ (:classpath sub-result) ; Use classpath from included result
+ (:java-cmd-path sub-result) ; Use java path from included result
+ (concat warnings (:warnings sub-result) [(str "Included options from: " included-path-str)])) ; Accumulate warnings
+ ;; Include failed (e.g., nested include file not found): Add a warning, keep *current* state, and continue.
+ (recur remaining-lines options classpath java-cmd-path
+ (conj warnings (str "Failed to parse included options from '" included-path-str "': " (:error sub-result))))))
+ ;; Include path is not a file: Add a warning, keep *current* state, and continue.
+ (recur remaining-lines options classpath java-cmd-path
+ (conj warnings (str "Included options path is not a file or not found: '" included-path-str "'")))))
+
+ ;; --- Handle -java-cmd directive ---
+ ;; Specifies the path to the Java executable. The last one encountered wins.
+ (str/starts-with? trimmed-line "-java-cmd ")
+ (let [path-from-directive (subst-fn (str/trim (subs trimmed-line (count "-java-cmd "))))]
+ ;; Update the java-cmd-path and continue with remaining lines.
+ (recur remaining-lines
+ options
+ classpath
+ path-from-directive ; This value replaces the previous one
+ warnings))
+
+ ;; --- Handle -classpath directive (Replace) ---
+ ;; Replaces the entire classpath with the substituted value.
+ (str/starts-with? trimmed-line "-classpath ")
+ (recur remaining-lines options (subst-fn (str/trim (subs trimmed-line (count "-classpath ")))) java-cmd-path warnings)
+
+ ;; --- Handle -classpath/a directive (Append) ---
+ ;; Appends the substituted value to the classpath, using the configured path separator.
+ (str/starts-with? trimmed-line "-classpath/a")
+ (let [path-to-append (subst-fn (str/trim (subs trimmed-line (count "-classpath/a"))))]
+ (recur remaining-lines options (if (str/blank? classpath) path-to-append (str classpath path-separator path-to-append)) java-cmd-path warnings))
+
+ ;; --- Handle -classpath/p directive (Prepend) ---
+ ;; Prepends the substituted value to the classpath, using the configured path separator.
+ (str/starts-with? trimmed-line "-classpath/p")
+ (let [path-to-prepend (subst-fn (str/trim (subs trimmed-line (count "-classpath/p"))))]
+ (recur remaining-lines options (if (str/blank? classpath) path-to-prepend (str path-to-prepend path-separator classpath)) java-cmd-path warnings))
+
+ ;; --- Handle regular JVM option ---
+ ;; Assume any other non-comment, non-blank line is a standard JVM option.
+ :else
+ ;; Apply substitution and add it to the options list.
+ (recur remaining-lines (conj options (subst-fn trimmed-line)) classpath java-cmd-path warnings))))))
+
+ ;; --- Handle File Not Found Error ---
+ ;; If the initial `read-file-fn` call fails for `file-path`.
+ (catch FileNotFoundException _
+ ;; Indicate failure, return the initial classpath, set java-cmd-path to nil, and provide error details.
+ {:ok? false :error :file-not-found :path file-path :options [] :classpath current-classpath :java-cmd-path nil :warnings []}))))
+
+(defn parse-vmoptions
+ "Public API for parsing a vmoptions file.
+ Initializes the state (empty classpath, nil java command path) and calls the
+ recursive `parse-vmoptions*` helper.
+
+ Parameters:
+ - `file-path`: Path to the root vmoptions file.
+ - `initial-classpath`: A classpath string to start with (typically \"\").
+ Allows prepending/appending relative to a base classpath if needed, although
+ the current `-main` usage starts with an empty string.
+ - `config`: The configuration map with injected dependencies (see `parse-vmoptions*`).
+
+ Returns:
+ The result map from `parse-vmoptions*` (see its docstring). Pure (given the config map)."
+ [file-path initial-classpath config]
+ (parse-vmoptions* file-path initial-classpath nil config))
+
+(defn determine-java-executable
+ "Determines the default Java executable path based on the JAVA_HOME environment variable.
+ Does *not* consider the `-java-cmd` directive from vmoptions; that's handled in `-main`.
+ Takes functions for environment lookup and file existence checks to enable testing. Pure.
+
+ Parameters:
+ - `getenv-fn`: Function to look up environment variables (e.g., `#(System/getenv %)`).
+ - `file-exists-fn`: Function to check if a file path exists (e.g., `#(.exists (io/file %))`).
+ - `file-separator`: The OS-specific file separator char (e.g., `/` or `\\`).
+
+ Returns:
+ The canonical path to `$JAVA_HOME/bin/java` if JAVA_HOME is set and the file exists,
+ otherwise defaults to the string \"java\" (relying on the system PATH)."
+ [getenv-fn file-exists-fn file-separator]
+ (let [java-home (getenv-fn "JAVA_HOME")]
+ (if (and java-home (not (str/blank? java-home)))
+ ;; If JAVA_HOME is set and non-blank, construct the potential path.
+ (let [;; Construct path like /path/to/java/home/bin/java
+ exec-path-str (str java-home file-separator "bin" file-separator "java")
+ ;; Using io/file locally to handle potential path normalization before checking existence.
+ exec-path (io/file exec-path-str)]
+ ;; Check existence using the canonical path to resolve symlinks etc.
+ (if (file-exists-fn (.getCanonicalPath exec-path))
+ (.getCanonicalPath exec-path) ;; Return the resolved, existing path
+ "java")) ; JAVA_HOME is set, but bin/java doesn't exist, fallback to default
+ "java"))) ; JAVA_HOME not set, use default
+
+(defn build-command-list
+ "Constructs the final command vector suitable for `ProcessBuilder`. Pure.
+
+ Parameters:
+ - `java-exec`: The determined path to the Java executable.
+ - `vm-opts`: A sequence of JVM option strings.
+ - `final-cp`: The fully constructed classpath string.
+ - `main-cls`: The fully qualified name of the main class to execute.
+ - `args`: A sequence of arguments to pass to the main class.
+
+ Returns:
+ A vector of strings representing the command and its arguments
+ (e.g., [\"/path/to/java\" \"-Xmx1g\" \"-cp\" \"lib.jar\" \"com.example.Main\" \"arg1\"])."
+ [java-exec vm-opts final-cp main-cls args]
+ (-> [java-exec] ; Start with the java executable path
+ (into vm-opts) ; Add all JVM options
+ (into ["-cp" final-cp]) ; Add the classpath flag and value
+ (conj main-cls) ; Add the main class name
+ (into args))) ; Add arguments for the target application
+
+;; =============================================
+;; Main Function (Orchestration & Side Effects)
+;; =============================================
+;; This is the application entry point. It orchestrates the process:
+;; 1. Sets up real environment access functions (file I/O, env vars).
+;; 2. Calls the pure helper functions (`parse-vmoptions`, `determine-java-executable`, `build-command-list`)
+;; with the real environment accessors passed via the `config` map.
+;; 3. Performs side effects: logging, launching the child process, managing shutdown.
+
+(defn -main [& args]
+ ;; --- Define Real Side-Effecting Dependencies ---
+ ;; Create functions that perform actual system interactions. These will be
+ ;; passed into the pure helper functions via the `config` map.
+ (let [real-getenv (fn ([var] (System/getenv var))) ; Real environment variable lookup
+ real-read-file slurp ; Real file reading
+ real-is-file #(.isFile (io/file %)) ; Real check if path is a file
+ real-file-exists #(.exists (io/file %)) ; Real check if path exists
+ os-file-separator File/separator ; System file separator ("/" or "\\")
+ os-path-separator File/pathSeparator ; System path separator (":" or ";")
+
+ ;; Configuration map bundling the real dependencies for the pure functions.
+ config {:getenv-fn real-getenv
+ :read-file-fn real-read-file
+ :is-file-fn real-is-file
+ :path-separator os-path-separator}
+
+ ;; --- Configuration ---
+ vmoptions-file-path "engine.vmoptions" ; Default location of the options file
+ mirth-launcher-jar "mirth-server-launcher.jar" ; Hardcoded target application JAR
+ main-class "com.mirth.connect.server.launcher.MirthLauncher" ; Hardcoded target main class
+
+ ;; --- State Management for Child Process ---
+ process-atom (atom nil) ; Holds the launched Process object, nil if not running. Used by shutdown hook.
+ shutting-down?-atom (atom false) ; Flag to prevent shutdown hook race conditions.
+
+ ;; --- Step 1: Parse vmoptions file ---
+ ;; Use `real-is-file` to check existence before parsing.
+ parse-result (if (real-is-file vmoptions-file-path)
+ ;; File exists, parse it with an empty initial classpath.
+ (parse-vmoptions vmoptions-file-path "" config)
+ ;; File doesn't exist or isn't a file, create a default 'ok' result with a warning.
+ {:ok? true :options [] :classpath "" :java-cmd-path nil :warnings [(str "vmoptions file not found or not a file: " vmoptions-file-path)]})
+
+ ;; Log any warnings generated during parsing (e.g., included file issues).
+ _ (doseq [warning (:warnings parse-result)] (println "WARNING:" warning))
+
+ ;; Optional: Could add a check here to exit if `(:ok? parse-result)` is false,
+ ;; indicating a fatal parsing error like the root vmoptions file not being found
+ ;; by `read-file-fn` inside `parse-vmoptions`. Currently, it proceeds even on error,
+ ;; using potentially incomplete options/classpath.
+
+ ;; --- Step 2: Determine Final Java Executable Path ---
+ ;; Prioritize the path from the `-java-cmd` directive if it was present in vmoptions.
+ java-cmd-directive-path (when (:ok? parse-result) ; Only consider if parsing itself was okay.
+ (let [raw-path (get parse-result :java-cmd-path)]
+ ;; Ensure the path from the directive is not nil or blank.
+ (when (and raw-path (not (str/blank? raw-path)))
+ raw-path)))
+
+ ;; Decide which Java executable path to use.
+ final-java-executable (if (and java-cmd-directive-path (real-file-exists java-cmd-directive-path))
+ ;; If -java-cmd path was provided AND the file actually exists, use it.
+ (do (println (str "Using Java executable from -java-cmd directive: " java-cmd-directive-path))
+ java-cmd-directive-path)
+ ;; Otherwise, fall back to standard determination (JAVA_HOME or default 'java').
+ (let [determined-path (determine-java-executable real-getenv real-file-exists os-file-separator)]
+ ;; Log appropriately based on whether an invalid directive was present.
+ (if java-cmd-directive-path
+ (println (str "WARNING: Path from -java-cmd ('" java-cmd-directive-path "') not found or invalid. Using determined Java executable: " determined-path))
+ (println (str "Using determined Java executable: " determined-path)))
+ determined-path)) ; Use the determined path
+
+ ;; --- Step 3: Extract other results and Build Command ---
+ ;; Get the parsed JVM options. Default to empty list if parsing failed.
+ vm-options (if (:ok? parse-result) (:options parse-result) [])
+ ;; Get the classpath constructed during parsing. Default to empty string if parsing failed.
+ parsed-classpath (if (:ok? parse-result) (:classpath parse-result) "")
+
+ ;; Construct the final classpath string: Prepend the specific launcher JAR.
+ final-classpath (if (str/blank? parsed-classpath)
+ mirth-launcher-jar ; If vmoptions didn't specify any CP, use only mirth-launcher-jar JAR.
+ (str mirth-launcher-jar os-path-separator parsed-classpath)) ; Prepend mirth-launcher-jar otherwise.
+
+ ;; Assemble the complete command vector using the pure helper function.
+ command (build-command-list final-java-executable vm-options final-classpath main-class args)
+
+ ;; --- Side Effects Execution: Launching and Management ---
+ ;; Log the command that will be executed.
+ _ (println "Launching Engine with command:" (str/join " " command))
+
+ ;; Setup a JVM shutdown hook. This runs if the *launcher* JVM is terminated (e.g., Ctrl+C).
+ shutdown-hook (Thread. (fn []
+ ;; Set flag to indicate shutdown is in progress (for finally block).
+ (reset! shutting-down?-atom true)
+ ;; If the child process reference exists...
+ (when-let [proc @process-atom]
+ (println "\nLauncher shutting down, attempting to terminate Engine process...")
+ (try
+ ;; Attempt to terminate the child process.
+ (.destroy proc)
+ (catch Exception e (println "ERROR during shutdown hook:" (.getMessage e)))))))
+ _ (.addShutdownHook (Runtime/getRuntime) shutdown-hook)
+
+ ;; Variable to hold the exit code of the child process.
+ exit-code (try
+ ;; --- Launch the Child Process ---
+ (let [;; Create a ProcessBuilder with the command list.
+ ;; .inheritIO() redirects child's stdout/stderr/stdin to launcher's streams.
+ process (.start (.inheritIO (ProcessBuilder. ^java.util.List command)))]
+ ;; Store the running Process object in the atom so the shutdown hook can access it.
+ (reset! process-atom process)
+ ;; Wait for the child process to complete and get its exit code.
+ ;; This blocks the launcher thread until the server exits.
+ (.waitFor process))
+ ;; --- Handle Launch Errors ---
+ (catch IOException e
+ (println (str "ERROR: Could not start Engine process: " (.getMessage e)))
+ (println "Check java executable path, JAR path validity, and command details:")
+ (println (str/join " " command))
+ 1) ; Return a non-zero exit code indicating launch failure.
+ ;; --- Cleanup ---
+ (finally
+ ;; Clear the process atom now that the process has terminated or failed to start.
+ (reset! process-atom nil)
+ ;; Remove the shutdown hook *if* we are not currently exiting *because* of the shutdown hook.
+ ;; This prevents errors if the hook tries to remove itself while running.
+ (when (not @shutting-down?-atom)
+ (try
+ (.removeShutdownHook (Runtime/getRuntime) shutdown-hook)
+ ;; Catch exception if hook is already running or already removed.
+ (catch IllegalStateException _)))))]
+
+ ;; Exit the launcher JVM, propagating the exit code from the child process (or 1 if launch failed).
+ (System/exit exit-code)))
diff --git a/tools/engine-launcher/test/org/openintegrationengine/engine/server/launcher_test.clj b/tools/engine-launcher/test/org/openintegrationengine/engine/server/launcher_test.clj
new file mode 100644
index 000000000..b83a99946
--- /dev/null
+++ b/tools/engine-launcher/test/org/openintegrationengine/engine/server/launcher_test.clj
@@ -0,0 +1,297 @@
+;;;
+;;; SPDX-FileCopyrightText: 2025 Tony Germano tony@germano.name
+;;;
+;;; SPDX-License-Identifier: MPL-2.0
+;;;
+
+;;;;
+;; File: launcher_test.clj
+;; Purpose: Unit tests for the OIE Engine Launcher (`launcher.clj`).
+;;
+;; These tests verify the behavior of the core, pure logic helper functions
+;; defined in the `org.openintegrationengine.engine.server.launcher` namespace.
+;;
+;; Testing Strategy:
+;; - Focus on unit testing the deterministic helper functions (`substitute-env-vars`,
+;; `parse-vmoptions`, `determine-java-executable`, `build-command-list`).
+;; - Employ mocking for external dependencies (file system access, environment variables)
+;; to isolate the logic under test and ensure predictable, fast execution.
+;; - The `-main` function, being primarily concerned with orchestrating side effects,
+;; is *not* unit tested here. Its behavior should be validated through integration tests
+;; if necessary, as its core logic components are already tested via the helpers.
+;;;;
+(ns org.openintegrationengine.engine.server.launcher-test
+ (:require [clojure.test :refer :all]
+ [clojure.string :as str]
+ ;; Require the namespace under test, aliased for clarity and brevity.
+ [org.openintegrationengine.engine.server.launcher :as launcher])
+ (:import [java.io File FileNotFoundException])) ; Import exception for mock file reading tests.
+
+;; === Tests for substitute-env-vars ===
+;; Verifies the environment variable substitution logic.
+
+(deftest substitute-env-vars-test
+ ;; Define a simple mock environment (map) and a getter function for it.
+ (let [mock-env {"EXISTING_VAR" "VAR_VALUE"
+ "OTHER_VAR" "OTHER_VALUE"}
+ ;; This function simulates System/getenv for the test.
+ mock-getenv (fn [var-name] (get mock-env var-name))]
+
+ (testing "String with no variables - should remain unchanged"
+ (is (= "hello world" (launcher/substitute-env-vars "hello world" mock-getenv))))
+
+ (testing "String with existing variable - should substitute value"
+ (is (= "hello VAR_VALUE" (launcher/substitute-env-vars "hello ${EXISTING_VAR}" mock-getenv))))
+
+ (testing "String with multiple existing variables"
+ (is (= "VAR_VALUE meets OTHER_VALUE" (launcher/substitute-env-vars "${EXISTING_VAR} meets ${OTHER_VAR}" mock-getenv))))
+
+ (testing "String with non-existent variable - should substitute empty string"
+ (is (= "hello " (launcher/substitute-env-vars "hello ${NON_EXISTENT_VAR}" mock-getenv))))
+
+ (testing "String with mixed existing and non-existent variables"
+ (is (= "VAR_VALUE and " (launcher/substitute-env-vars "${EXISTING_VAR} and ${NON_EXISTENT_VAR}" mock-getenv))))
+
+ (testing "Empty string input - should return empty string"
+ (is (= "" (launcher/substitute-env-vars "" mock-getenv))))))
+
+;; === Tests for parse-vmoptions ===
+;; Verifies the core vmoptions parsing logic, including directives and includes.
+;; This uses a more extensive mocking setup due to file I/O and env var dependencies.
+
+(deftest parse-vmoptions-test
+ ;; --- Mocking Setup ---
+ ;; Simulate a file system using a map from path to content.
+ (let [mock-files {"main.vmoptions" (str "-Xmx512m\n"
+ "-Dprop=${ENV_PROP}\n" ; Substitution test
+ "-java-cmd /specific/java\n" ; Will be overridden by include
+ "-include-options included.vmoptions\n" ; Include directive
+ "-classpath/a /main/append") ; Classpath append
+
+ "included.vmoptions" (str "# A comment\n"
+ "-XincOpt\n" ; Option from included file
+ "-java-cmd /included/java\n" ; Overrides main's -java-cmd
+ "-classpath/p /included/prepend") ; Classpath prepend
+
+ "override.vmoptions" (str "-include-options included.vmoptions\n" ; Include sets -java-cmd first
+ "-java-cmd /override/java") ; This should take precedence
+
+ "env_java.vmoptions" "-java-cmd ${JAVA_CMD_PATH}" ; -java-cmd value from env var
+ "cp_replace.vmoptions" "-classpath /new/path" ; Replace classpath directive
+ "cp_append.vmoptions" "-classpath/a /append" ; Append classpath directive
+ "cp_prepend.vmoptions" "-classpath/p /prepend" ; Prepend classpath directive
+ "empty.vmoptions" "" ; Empty file test case
+ "only_comments.vmoptions" "# line 1\n # line 2"} ; Comments-only test case
+
+ ;; Simulate environment variables needed for substitutions.
+ mock-env {"ENV_PROP" "env-value"
+ "JAVA_CMD_PATH" "/env/java/path"}
+
+ ;; Mock implementations of the functions required by the `config` map:
+ ;; Simulates reading a file from our mock file system.
+ mock-read-file (fn [path]
+ (if-let [content (get mock-files path)]
+ content
+ (throw (FileNotFoundException. (str "Mock file not found: " path)))))
+ ;; Simulates getting an environment variable from our mock environment.
+ mock-getenv (fn [var-name] (get mock-env var-name))
+ ;; Simulates checking if a path corresponds to a file in our mock system.
+ mock-is-file (fn [path] (contains? mock-files path))
+
+ ;; The `config` map passed to the parser, using our mock functions.
+ ;; Uses Unix path separator for consistency in tests.
+ test-config {:read-file-fn mock-read-file
+ :getenv-fn mock-getenv
+ :is-file-fn mock-is-file
+ :path-separator ":"}] ; Use ':' for testing classpath logic
+
+
+ (testing "Parsing basic file with includes, substitutions, and directive precedence"
+ (let [result (launcher/parse-vmoptions "main.vmoptions" "initial/cp" test-config)]
+ (is (:ok? result) "Parsing should succeed")
+ ;; Options from both files, substitution applied.
+ (is (= ["-Xmx512m" "-Dprop=env-value" "-XincOpt"] (:options result)))
+ ;; Classpath: prepend from include, initial, append from main.
+ (is (= "/included/prepend:initial/cp:/main/append" (:classpath result)))
+ ;; Java command path from *included* file should win as it's processed last within its scope.
+ (is (= "/included/java" (:java-cmd-path result)))
+ ;; Should have a warning about the include.
+ (is (= 1 (count (:warnings result))))
+ (is (str/includes? (first (:warnings result)) "Included options from: included.vmoptions"))))
+
+ (testing "Parsing file where a later -java-cmd overrides an included one"
+ (let [result (launcher/parse-vmoptions "override.vmoptions" "" test-config)]
+ (is (:ok? result))
+ (is (= ["-XincOpt"] (:options result))) ; Options from include
+ (is (= "/included/prepend" (:classpath result))) ; Classpath from include
+ ;; The -java-cmd in override.vmoptions takes precedence over the one in included.vmoptions.
+ (is (= "/override/java" (:java-cmd-path result)))
+ (is (= 1 (count (:warnings result))))))
+
+ (testing "Parsing file with -java-cmd specified via environment variable"
+ (let [result (launcher/parse-vmoptions "env_java.vmoptions" "" test-config)]
+ (is (:ok? result))
+ (is (empty? (:options result)))
+ (is (= "" (:classpath result)))
+ ;; Path should be the substituted value from mock-env.
+ (is (= "/env/java/path" (:java-cmd-path result)))
+ (is (empty? (:warnings result)))))
+
+ (testing "Parsing an empty vmoptions file"
+ (let [result (launcher/parse-vmoptions "empty.vmoptions" "" test-config)]
+ (is (:ok? result))
+ (is (empty? (:options result)))
+ (is (= "" (:classpath result)))
+ (is (nil? (:java-cmd-path result))) ; No directive encountered
+ (is (empty? (:warnings result)))))
+
+ (testing "Parsing a file containing only comments and blank lines"
+ (let [result (launcher/parse-vmoptions "only_comments.vmoptions" "" test-config)]
+ (is (:ok? result))
+ (is (empty? (:options result)))
+ (is (= "" (:classpath result)))
+ (is (nil? (:java-cmd-path result)))
+ (is (empty? (:warnings result)))))
+
+ (testing "Classpath directive: -classpath (Replace)"
+ (let [result (launcher/parse-vmoptions "cp_replace.vmoptions" "old/path" test-config)]
+ (is (:ok? result))
+ (is (empty? (:options result)))
+ ;; The initial "old/path" should be replaced entirely.
+ (is (= "/new/path" (:classpath result)))))
+
+ (testing "Classpath directive: -classpath/a (Append) to existing path"
+ (let [result (launcher/parse-vmoptions "cp_append.vmoptions" "initial" test-config)]
+ (is (:ok? result))
+ (is (= "initial:/append" (:classpath result)))))
+
+ (testing "Classpath directive: -classpath/a (Append) to empty initial path"
+ (let [result (launcher/parse-vmoptions "cp_append.vmoptions" "" test-config)]
+ (is (:ok? result))
+ ;; Should just be the appended path.
+ (is (= "/append" (:classpath result)))))
+
+ (testing "Classpath directive: -classpath/p (Prepend) to existing path"
+ (let [result (launcher/parse-vmoptions "cp_prepend.vmoptions" "initial" test-config)]
+ (is (:ok? result))
+ (is (= "/prepend:initial" (:classpath result)))))
+
+ (testing "Classpath directive: -classpath/p (Prepend) to empty initial path"
+ (let [result (launcher/parse-vmoptions "cp_prepend.vmoptions" "" test-config)]
+ (is (:ok? result))
+ ;; Should just be the prepended path.
+ (is (= "/prepend" (:classpath result)))))
+
+ (testing "Error handling: Included file specified but not found by mock is-file-fn"
+ ;; Modify the config for this test case only, so is-file-fn always returns false.
+ (let [config-no-include (assoc test-config :is-file-fn (constantly false))
+ result (launcher/parse-vmoptions "main.vmoptions" "" config-no-include)]
+ ;; Parsing of the main file itself should still succeed.
+ (is (:ok? result))
+ ;; Options from main file (with substitution) should be present.
+ (is (= ["-Xmx512m" "-Dprop=env-value"] (:options result)))
+ ;; Classpath append from main file should still work relative to initial "".
+ (is (= "/main/append" (:classpath result)))
+ ;; The -java-cmd from the main file should be effective as the include was skipped.
+ (is (= "/specific/java" (:java-cmd-path result)))
+ ;; A warning about the missing include should be generated.
+ (is (= 1 (count (:warnings result))))
+ (is (str/includes? (first (:warnings result)) "not a file or not found: 'included.vmoptions'"))))
+
+ (testing "Error handling: Main vmoptions file not found"
+ ;; Try parsing a path not present in `mock-files`.
+ (let [result (launcher/parse-vmoptions "/non/existent/path.vmoptions" "initial/cp" test-config)]
+ ;; ok? should be false, indicating a fatal error during parsing.
+ (is (false? (:ok? result)))
+ ;; Should indicate the specific error type and path.
+ (is (= :file-not-found (:error result)))
+ (is (= "/non/existent/path.vmoptions" (:path result)))
+ ;; In case of failure, it should return the initial classpath passed in.
+ (is (= "initial/cp" (:classpath result)))
+ ;; Options should be empty.
+ (is (empty? (:options result)))
+ ;; java-cmd-path should be nil as parsing failed early.
+ (is (nil? (:java-cmd-path result)))))))
+
+
+;; === Tests for determine-java-executable ===
+;; Verifies the logic for finding the default Java executable (based on JAVA_HOME or fallback).
+;; Uses mock environment and file existence checks.
+
+(deftest determine-java-executable-test
+ (testing "JAVA_HOME set and corresponding bin/java exists"
+ (let [mock-env {"JAVA_HOME" "/opt/java"}
+ ;; Simulate only the expected java path existing.
+ mock-exists #(= % "/opt/java/bin/java")]
+ (is (= "/opt/java/bin/java"
+ (launcher/determine-java-executable (fn [v] (get mock-env v)) mock-exists File/separator)))))
+
+ (testing "JAVA_HOME set but corresponding bin/java does NOT exist"
+ (let [mock-env {"JAVA_HOME" "/opt/java"}
+ ;; Simulate no files existing.
+ mock-exists (constantly false)]
+ ;; Should fall back to the default "java".
+ (is (= "java"
+ (launcher/determine-java-executable (fn [v] (get mock-env v)) mock-exists File/separator)))))
+
+ (testing "JAVA_HOME environment variable is not set"
+ (let [mock-env {} ; Empty environment
+ ;; Assume "java" would be found on PATH (mock doesn't need to check PATH).
+ mock-exists (constantly true)]
+ ;; Should use the default "java".
+ (is (= "java"
+ (launcher/determine-java-executable (fn [v] (get mock-env v)) mock-exists File/separator))))))
+
+;; === Tests for build-command-list ===
+;; Verifies the construction of the final command vector for ProcessBuilder.
+
+(deftest build-command-list-test
+ (testing "Basic command construction with all elements"
+ (is (= ["/usr/bin/java" "-Xmx1g" "-Dprop=val" "-cp" "app.jar:/lib/*" "com.app.Main" "arg1" "--flag"]
+ (launcher/build-command-list "/usr/bin/java"
+ ["-Xmx1g" "-Dprop=val"]
+ "app.jar:/lib/*"
+ "com.app.Main"
+ ["arg1" "--flag"]))))
+
+ (testing "Command construction with no VM options"
+ (is (= ["java" "-cp" "app.jar" "com.app.Main" "arg"]
+ (launcher/build-command-list "java"
+ [] ; Empty options
+ "app.jar"
+ "com.app.Main"
+ ["arg"]))))
+
+ (testing "Command construction with no pass-through arguments"
+ (is (= ["java" "-Xmx512m" "-cp" "app.jar" "com.app.Main"]
+ (launcher/build-command-list "java"
+ ["-Xmx512m"]
+ "app.jar"
+ "com.app.Main"
+ [])))) ; Empty args
+
+ (testing "Command construction with only essential elements"
+ (is (= ["java" "-cp" "main.jar" "com.app.Start"]
+ (launcher/build-command-list "java"
+ []
+ "main.jar"
+ "com.app.Start"
+ [])))))
+
+;; === Note on Testing `-main` ===
+;;;;
+;; The `-main` function is intentionally *not* covered by these unit tests.
+;; Its primary role is orchestration: integrating the results from the helper
+;; functions (which *are* tested here) and managing side effects like file I/O,
+;; process execution (`ProcessBuilder`), and system interactions (`System/exit`,
+;; shutdown hooks).
+;;
+;; Unit testing `-main` directly would require extensive mocking of Java's
+;; `ProcessBuilder`, `Runtime`, `System`, etc., which becomes complex and brittle.
+;;
+;; The correctness of `-main` relies on the correctness of the helper functions
+;; tested above. If end-to-end validation of the launcher executable is required,
+;; it should be done via **integration tests** that execute the compiled JAR
+;; in a controlled environment and verify its behavior and the state of the
+;; launched child process.
+;;;;