defmodule OrgGarden.Quartz do @moduledoc """ Manages Quartz Node.js process as an Erlang Port. Required environment: - QUARTZ_PATH: path to quartz repo (with node_modules) - NODE_PATH: path to node executable (default: "node") Starts Quartz in serve mode (`npx quartz build --serve`) and forwards all stdout/stderr output to the Logger with a `[quartz]` prefix. If Quartz exits, this GenServer will stop, which triggers the supervisor to restart the entire supervision tree (strategy: :one_for_all). """ use GenServer require Logger defstruct [:port, :quartz_path, :content_dir, :http_port, :ws_port] # ------------------------------------------------------------------- # Client API # ------------------------------------------------------------------- @doc """ Start the Quartz process as a linked GenServer. ## Options * `:content_dir` — directory where markdown files are located (required) * `:port` — HTTP server port (default: 8080) * `:ws_port` — WebSocket hot reload port (default: 3001) """ def start_link(opts) do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end # ------------------------------------------------------------------- # GenServer callbacks # ------------------------------------------------------------------- @impl true def init(opts) do quartz_path = System.get_env("QUARTZ_PATH") || raise "QUARTZ_PATH environment variable not set" node_path = System.get_env("NODE_PATH", "node") content_dir = Keyword.fetch!(opts, :content_dir) http_port = Keyword.get(opts, :port, 8080) ws_port = Keyword.get(opts, :ws_port, 3001) cli_path = Path.join(quartz_path, "quartz/bootstrap-cli.mjs") unless File.exists?(cli_path) do raise "Quartz CLI not found at #{cli_path}. Check QUARTZ_PATH." end args = [ cli_path, "build", "--serve", "--directory", content_dir, "--port", to_string(http_port), "--wsPort", to_string(ws_port) ] Logger.info("[quartz] Starting: #{node_path} #{Enum.join(args, " ")}") Logger.info("[quartz] Working directory: #{quartz_path}") port = Port.open({:spawn_executable, node_path}, [ :binary, :exit_status, :stderr_to_stdout, args: args, cd: quartz_path, env: [{~c"NODE_NO_WARNINGS", ~c"1"}] ]) state = %__MODULE__{ port: port, quartz_path: quartz_path, content_dir: content_dir, http_port: http_port, ws_port: ws_port } {:ok, state} end @impl true def handle_info({port, {:data, data}}, %{port: port} = state) do data |> String.split("\n", trim: true) |> Enum.each(&Logger.info("[quartz] #{&1}")) {:noreply, state} end @impl true def handle_info({port, {:exit_status, status}}, %{port: port} = state) do Logger.error("[quartz] Process exited with status #{status}") {:stop, {:quartz_exit, status}, state} end @impl true def terminate(_reason, %{port: port}) when is_port(port) do # Attempt graceful shutdown Port.close(port) :ok rescue _ -> :ok end def terminate(_reason, _state), do: :ok end