diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..d304ff3 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore index b4dfc59..8f03dec 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ erl_crash.dump *.tar /tmp/ -org_garden +/org_garden # Nix result diff --git a/README.org b/README.org index 8b5c399..08a1b1d 100644 --- a/README.org +++ b/README.org @@ -7,7 +7,7 @@ An [[https://orgmode.org/][org-roam]] to static website publishing pipeline. Con #+begin_example org-garden serve # dev server with live reload org-garden build # production static build -org-garden export # org → markdown only +org-garden export # org -> markdown only #+end_example * Running with Nix (recommended) @@ -26,3 +26,52 @@ mix escript.build #+end_src Requires =QUARTZ_PATH= to point to a Quartz install with =node_modules= for =serve= and =build= commands. + +* NixOS Service + +A NixOS module is provided for running org-garden as a systemd service: + +#+begin_src nix +{ + inputs.org-garden.url = "github:ignacio.ballesteros/org-garden"; + + outputs = { self, nixpkgs, org-garden }: { + nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { + modules = [ + org-garden.nixosModules.default + { + services.org-garden = { + enable = true; + package = org-garden.packages.x86_64-linux.default; + notesDir = /path/to/notes; + port = 8080; + }; + } + ]; + }; + }; +} +#+end_src + +* Health Checks + +When running in serve mode, health endpoints are available on port 9090: + +- =GET /health/live= — liveness probe (always 200) +- =GET /health/ready= — readiness probe (200 if all components ready) +- =GET /health= — JSON status of all components + +* Environment Variables + +| Variable | Default | Description | +|----------------+---------------------------+----------------------------------| +| =QUARTZ_PATH= | (required for serve/build)| Path to Quartz installation | +| =NODE_PATH= | =node= | Node.js executable | +| =NOTES_DIR= | (cli arg) | Source notes directory | +| =OUTPUT_DIR= | =.= | Output base directory | +| =ZOTERO_URL= | =http://localhost:23119= | Zotero Better BibTeX URL | +| =BIBTEX_FILE= | (none) | Fallback BibTeX file | +| =CITATION_MODE=| =warn= | =silent=, =warn=, or =strict= | +| =PORT= | =8080= | HTTP server port | +| =WS_PORT= | =3001= | WebSocket hot reload port | +| =HEALTH_PORT= | =9090= | Health check endpoint port | diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..249440a --- /dev/null +++ b/config/config.exs @@ -0,0 +1,18 @@ +import Config + +# Default configuration values +# These are overridden by config/runtime.exs in releases + +config :org_garden, + zotero_url: "http://localhost:23119", + citation_mode: :warn, + http_port: 8080, + ws_port: 3001, + health_port: 9090 + +config :logger, :console, + format: "$time $metadata[$level] $message\n", + metadata: [:module] + +# Import environment specific config +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..4693768 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,4 @@ +import Config + +# Development-specific configuration +config :logger, level: :debug diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..c9b6224 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,6 @@ +import Config + +# Production-specific configuration +# Most config comes from runtime.exs via environment variables + +config :logger, level: :info diff --git a/config/runtime.exs b/config/runtime.exs new file mode 100644 index 0000000..5ebd593 --- /dev/null +++ b/config/runtime.exs @@ -0,0 +1,42 @@ +import Config + +# Runtime configuration from environment variables +# This file is executed at runtime (not compile time) + +defmodule RuntimeConfig do + def parse_int(nil, default), do: default + def parse_int(val, _default) when is_integer(val), do: val + + def parse_int(val, default) when is_binary(val) do + case Integer.parse(val) do + {int, ""} -> int + _ -> default + end + end + + def parse_citation_mode("silent"), do: :silent + def parse_citation_mode("strict"), do: :strict + def parse_citation_mode(_), do: :warn +end + +# Core paths +if quartz_path = System.get_env("QUARTZ_PATH") do + config :org_garden, quartz_path: quartz_path +end + +config :org_garden, + node_path: System.get_env("NODE_PATH", "node"), + notes_dir: System.get_env("NOTES_DIR"), + output_dir: System.get_env("OUTPUT_DIR") + +# Citation configuration +config :org_garden, + zotero_url: System.get_env("ZOTERO_URL", "http://localhost:23119"), + bibtex_file: System.get_env("BIBTEX_FILE"), + citation_mode: RuntimeConfig.parse_citation_mode(System.get_env("CITATION_MODE")) + +# Server ports +config :org_garden, + http_port: RuntimeConfig.parse_int(System.get_env("PORT"), 8080), + ws_port: RuntimeConfig.parse_int(System.get_env("WS_PORT"), 3001), + health_port: RuntimeConfig.parse_int(System.get_env("HEALTH_PORT"), 9090) diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..8902478 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,4 @@ +import Config + +# Test-specific configuration +config :logger, level: :warning diff --git a/flake.nix b/flake.nix index bb23d6b..68ff7fe 100644 --- a/flake.nix +++ b/flake.nix @@ -7,6 +7,15 @@ }; outputs = { self, nixpkgs, flake-utils }: + let + # System-independent outputs + nixosModule = import ./nix/module.nix; + in + { + # NixOS module (system-independent) + nixosModules.default = nixosModule; + nixosModules.org-garden = nixosModule; + } // flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; @@ -47,6 +56,8 @@ ./mix.exs ./mix.lock ./lib + ./config + ./rel ]; }; escriptBinName = "org_garden"; diff --git a/lib/org_garden/application.ex b/lib/org_garden/application.ex index bbcfb55..5c4946d 100644 --- a/lib/org_garden/application.ex +++ b/lib/org_garden/application.ex @@ -1,14 +1,39 @@ defmodule OrgGarden.Application do - @moduledoc false + @moduledoc """ + OTP Application for org-garden. + + Starts core infrastructure services: + - Finch HTTP client pool + - Process registry for named lookups + - DynamicSupervisor for runtime-started services + - Health check HTTP server + + Service-mode components (Watcher, Quartz) are started dynamically + via OrgGarden.Server when running in serve mode. + """ use Application @impl true def start(_type, _args) do children = [ - {Finch, name: OrgGarden.Finch} + # HTTP client pool for Zotero/external requests + {Finch, name: OrgGarden.Finch}, + + # Process registry for named lookups + {Registry, keys: :unique, name: OrgGarden.Registry}, + + # Dynamic supervisor for serve mode components + {DynamicSupervisor, name: OrgGarden.DynamicSupervisor, strategy: :one_for_one}, + + # Health check HTTP server + {Bandit, plug: OrgGarden.Health, port: health_port(), scheme: :http} ] opts = [strategy: :one_for_one, name: OrgGarden.AppSupervisor] Supervisor.start_link(children, opts) end + + defp health_port do + Application.get_env(:org_garden, :health_port, 9090) + end end diff --git a/lib/org_garden/cli.ex b/lib/org_garden/cli.ex index 5b5074b..44abdb3 100644 --- a/lib/org_garden/cli.ex +++ b/lib/org_garden/cli.ex @@ -48,6 +48,8 @@ defmodule OrgGarden.CLI do require Logger + alias OrgGarden.Config + @transforms [OrgGarden.Transforms.Citations] def main(argv) do @@ -70,31 +72,36 @@ defmodule OrgGarden.CLI do def handle_serve(argv) do require_quartz_env() {notes_dir, output_dir, content_dir, opts} = parse_serve_args(argv) - pipeline_opts = build_pipeline_opts() - - # Initial batch export - wipe(content_dir) - export_all(notes_dir, output_dir) - run_pipeline(content_dir, pipeline_opts) - generate_index(content_dir) IO.puts("==> Starting development server...") - {:ok, _pid} = - OrgGarden.Supervisor.start_link( - notes_dir: notes_dir, - output_dir: output_dir, - content_dir: content_dir, - pipeline_opts: pipeline_opts, - transforms: @transforms, - port: opts[:port] || 8080, - ws_port: opts[:ws_port] || 3001 - ) + case OrgGarden.Server.start_link( + notes_dir: notes_dir, + output_dir: output_dir, + content_dir: content_dir, + port: opts[:port], + ws_port: opts[:ws_port] + ) do + {:ok, pid} -> + IO.puts("==> Server running at http://localhost:#{opts[:port] || Config.get(:http_port, 8080)}") + IO.puts("==> Watching #{notes_dir} for changes (Ctrl+C to stop)") - IO.puts("==> Server running at http://localhost:#{opts[:port] || 8080}") - IO.puts("==> Watching #{notes_dir} for changes (Ctrl+C to stop)") + # Wait for server to exit + ref = Process.monitor(pid) - Process.sleep(:infinity) + receive do + {:DOWN, ^ref, :process, ^pid, reason} -> + case reason do + :normal -> :ok + :shutdown -> :ok + {:shutdown, _} -> :ok + _ -> abort("Server crashed: #{inspect(reason)}") + end + end + + {:error, reason} -> + abort("Failed to start server: #{inspect(reason)}") + end end defp parse_serve_args(argv) do @@ -122,7 +129,7 @@ defmodule OrgGarden.CLI do def handle_build(argv) do quartz_path = require_quartz_env() {notes_dir, output_dir, content_dir, _opts} = parse_build_args(argv) - pipeline_opts = build_pipeline_opts() + pipeline_opts = Config.pipeline_opts() # Full batch export wipe(content_dir) @@ -130,7 +137,7 @@ defmodule OrgGarden.CLI do run_pipeline(content_dir, pipeline_opts) generate_index(content_dir) - node_path = System.get_env("NODE_PATH", "node") + node_path = Config.get(:node_path, "node") IO.puts("==> Building static site with Quartz...") @@ -177,7 +184,7 @@ defmodule OrgGarden.CLI do def handle_export(argv) do {notes_dir, output_dir, content_dir, watch?} = parse_export_args(argv) - pipeline_opts = build_pipeline_opts() + pipeline_opts = Config.pipeline_opts() # Phase 1-4: full batch export wipe(content_dir) @@ -197,16 +204,29 @@ defmodule OrgGarden.CLI do if watch? do IO.puts("==> Watching #{notes_dir} for .org changes... (Ctrl+C to stop)") - {:ok, _pid} = - OrgGarden.Watcher.start_link( - notes_dir: notes_dir, - output_dir: output_dir, - content_dir: content_dir, - pipeline_opts: pipeline_opts, - transforms: @transforms + {:ok, pid} = + DynamicSupervisor.start_child( + OrgGarden.DynamicSupervisor, + {OrgGarden.Watcher, + notes_dir: notes_dir, + output_dir: output_dir, + content_dir: content_dir, + pipeline_opts: pipeline_opts, + transforms: @transforms} ) - Process.sleep(:infinity) + # Wait for watcher to exit + ref = Process.monitor(pid) + + receive do + {:DOWN, ^ref, :process, ^pid, reason} -> + case reason do + :normal -> :ok + :shutdown -> :ok + {:shutdown, _} -> :ok + _ -> abort("Watcher crashed: #{inspect(reason)}") + end + end end end @@ -235,7 +255,7 @@ defmodule OrgGarden.CLI do dir [] -> - System.get_env("NOTES_DIR") || + Config.get(:notes_dir) || abort("Usage: org-garden #{command} [options]") end @@ -249,7 +269,7 @@ defmodule OrgGarden.CLI do end defp extract_output_dir(opts) do - (opts[:output] || System.get_env("OUTPUT_DIR") || File.cwd!()) + (opts[:output] || Config.get(:output_dir) || File.cwd!()) |> Path.expand() end @@ -328,7 +348,7 @@ defmodule OrgGarden.CLI do # --------------------------------------------------------------------------- defp require_quartz_env do - case System.get_env("QUARTZ_PATH") do + case Config.get(:quartz_path) do nil -> abort(""" Error: QUARTZ_PATH environment variable not set. @@ -355,19 +375,6 @@ defmodule OrgGarden.CLI do end end - defp build_pipeline_opts do - %{ - zotero_url: System.get_env("ZOTERO_URL", "http://localhost:23119"), - bibtex_file: System.get_env("BIBTEX_FILE"), - citation_mode: - case System.get_env("CITATION_MODE", "warn") do - "silent" -> :silent - "strict" -> :strict - _ -> :warn - end - } - end - defp abort(message) do IO.puts(:stderr, message) System.halt(1) diff --git a/lib/org_garden/config.ex b/lib/org_garden/config.ex new file mode 100644 index 0000000..844c6c5 --- /dev/null +++ b/lib/org_garden/config.ex @@ -0,0 +1,98 @@ +defmodule OrgGarden.Config do + @moduledoc """ + Centralized configuration access with validation. + + Provides a unified interface for accessing configuration values, + with support for defaults and required value validation. + + ## Usage + + OrgGarden.Config.get(:http_port) + #=> 8080 + + OrgGarden.Config.get!(:quartz_path) + #=> "/path/to/quartz" or raises if not set + + OrgGarden.Config.pipeline_opts() + #=> %{zotero_url: "...", bibtex_file: nil, citation_mode: :warn} + """ + + @doc """ + Get a configuration value with an optional default. + """ + def get(key, default \\ nil) do + Application.get_env(:org_garden, key, default) + end + + @doc """ + Get a required configuration value. Raises if not set. + """ + def get!(key) do + case Application.get_env(:org_garden, key) do + nil -> raise ArgumentError, "Missing required configuration: #{key}" + value -> value + end + end + + @doc """ + Build pipeline options map for transforms. + """ + def pipeline_opts do + %{ + zotero_url: get(:zotero_url, "http://localhost:23119"), + bibtex_file: get(:bibtex_file), + citation_mode: get(:citation_mode, :warn) + } + end + + @doc """ + Validate that all required configuration is present. + Returns :ok or {:error, reasons}. + """ + def validate do + errors = + [] + |> validate_quartz_path() + |> validate_citation_mode() + + case errors do + [] -> :ok + errors -> {:error, errors} + end + end + + @doc """ + Validate configuration and raise on errors. + """ + def validate! do + case validate() do + :ok -> :ok + {:error, errors} -> raise "Configuration errors: #{inspect(errors)}" + end + end + + # Private validation helpers + + defp validate_quartz_path(errors) do + case get(:quartz_path) do + nil -> + errors + + path -> + cli_path = Path.join(path, "quartz/bootstrap-cli.mjs") + + if File.exists?(cli_path) do + errors + else + [{:quartz_path, "bootstrap-cli.mjs not found at #{cli_path}"} | errors] + end + end + end + + defp validate_citation_mode(errors) do + case get(:citation_mode) do + mode when mode in [:silent, :warn, :strict] -> errors + other -> [{:citation_mode, "Invalid citation mode: #{inspect(other)}"} | errors] + end + end +end diff --git a/lib/org_garden/export.ex b/lib/org_garden/export.ex index 4dadd0e..727ecdd 100644 --- a/lib/org_garden/export.ex +++ b/lib/org_garden/export.ex @@ -16,6 +16,12 @@ defmodule OrgGarden.Export do """ @spec export_file(String.t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()} def export_file(orgfile, notes_dir, output_dir) do + OrgGarden.Telemetry.span_export(orgfile, fn -> + do_export_file(orgfile, notes_dir, output_dir) + end) + end + + defp do_export_file(orgfile, notes_dir, output_dir) do section = orgfile |> Path.dirname() diff --git a/lib/org_garden/health.ex b/lib/org_garden/health.ex new file mode 100644 index 0000000..e96b8ab --- /dev/null +++ b/lib/org_garden/health.ex @@ -0,0 +1,89 @@ +defmodule OrgGarden.Health do + @moduledoc """ + Health check HTTP endpoints. + + Provides liveness and readiness probes for systemd/kubernetes health checks. + + ## Endpoints + + * `GET /health/live` — Always returns 200 if the process is alive + * `GET /health/ready` — Returns 200 if all components are ready, 503 otherwise + * `GET /health` — Returns JSON status of all components + """ + + use Plug.Router + + plug(:match) + plug(:dispatch) + + # Liveness probe — is the process alive? + get "/health/live" do + send_resp(conn, 200, "ok") + end + + # Readiness probe — are all components ready to serve? + get "/health/ready" do + checks = run_checks() + all_healthy = Enum.all?(checks, fn {_name, status} -> status == :ok end) + + if all_healthy do + send_resp(conn, 200, "ready") + else + send_resp(conn, 503, "not ready") + end + end + + # Full health status as JSON + get "/health" do + checks = run_checks() + all_healthy = Enum.all?(checks, fn {_name, status} -> status == :ok end) + + status = + if all_healthy do + "healthy" + else + "degraded" + end + + body = + Jason.encode!(%{ + status: status, + checks: + Map.new(checks, fn {name, status} -> + {name, Atom.to_string(status)} + end) + }) + + conn + |> put_resp_content_type("application/json") + |> send_resp(if(all_healthy, do: 200, else: 503), body) + end + + match _ do + send_resp(conn, 404, "not found") + end + + # ------------------------------------------------------------------- + # Health checks + # ------------------------------------------------------------------- + + defp run_checks do + [ + {:server, check_server()}, + {:watcher, check_watcher()}, + {:quartz, check_quartz()} + ] + end + + defp check_server do + if Process.whereis(OrgGarden.Server), do: :ok, else: :not_running + end + + defp check_watcher do + if Process.whereis(OrgGarden.Watcher), do: :ok, else: :not_running + end + + defp check_quartz do + if Process.whereis(OrgGarden.Quartz), do: :ok, else: :not_running + end +end diff --git a/lib/org_garden/quartz.ex b/lib/org_garden/quartz.ex index 70af257..6826d19 100644 --- a/lib/org_garden/quartz.ex +++ b/lib/org_garden/quartz.ex @@ -16,7 +16,11 @@ defmodule OrgGarden.Quartz do require Logger - defstruct [:port, :quartz_path, :content_dir, :http_port, :ws_port] + alias OrgGarden.Config + + @shutdown_timeout 5_000 + + defstruct [:port, :os_pid, :quartz_path, :content_dir, :http_port, :ws_port] # ------------------------------------------------------------------- # Client API @@ -35,21 +39,27 @@ defmodule OrgGarden.Quartz do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end + @doc """ + Check if Quartz is running. + """ + def running? do + Process.whereis(__MODULE__) != nil + end + # ------------------------------------------------------------------- # GenServer callbacks # ------------------------------------------------------------------- @impl true def init(opts) do - quartz_path = - System.get_env("QUARTZ_PATH") || - raise "QUARTZ_PATH environment variable not set" + Process.flag(:trap_exit, true) - node_path = System.get_env("NODE_PATH", "node") + quartz_path = Config.get!(:quartz_path) + node_path = Config.get(: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) + http_port = Keyword.get(opts, :port, Config.get(:http_port, 8080)) + ws_port = Keyword.get(opts, :ws_port, Config.get(:ws_port, 3001)) cli_path = Path.join(quartz_path, "quartz/bootstrap-cli.mjs") @@ -61,9 +71,12 @@ defmodule OrgGarden.Quartz do cli_path, "build", "--serve", - "--directory", content_dir, - "--port", to_string(http_port), - "--wsPort", to_string(ws_port) + "--directory", + content_dir, + "--port", + to_string(http_port), + "--wsPort", + to_string(ws_port) ] Logger.info("[quartz] Starting: #{node_path} #{Enum.join(args, " ")}") @@ -79,8 +92,12 @@ defmodule OrgGarden.Quartz do env: [{~c"NODE_NO_WARNINGS", ~c"1"}] ]) + # Get the OS process ID for graceful shutdown + {:os_pid, os_pid} = Port.info(port, :os_pid) + state = %__MODULE__{ port: port, + os_pid: os_pid, quartz_path: quartz_path, content_dir: content_dir, http_port: http_port, @@ -102,17 +119,65 @@ defmodule OrgGarden.Quartz do @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} + {:stop, {:quartz_exit, status}, %{state | port: nil, os_pid: nil}} end @impl true - def terminate(_reason, %{port: port}) when is_port(port) do - # Attempt graceful shutdown - Port.close(port) - :ok - rescue - _ -> :ok + def handle_info({:EXIT, port, reason}, %{port: port} = state) do + Logger.warning("[quartz] Port terminated: #{inspect(reason)}") + {:stop, {:port_exit, reason}, %{state | port: nil, os_pid: nil}} end - def terminate(_reason, _state), do: :ok + @impl true + def terminate(_reason, %{os_pid: nil}) do + # Process already exited + :ok + end + + @impl true + def terminate(_reason, %{port: port, os_pid: os_pid}) do + Logger.info("[quartz] Shutting down gracefully...") + + # Send SIGTERM to the Node.js process + case System.cmd("kill", ["-TERM", to_string(os_pid)], stderr_to_stdout: true) do + {_, 0} -> + # Wait for graceful exit + wait_for_exit(port, @shutdown_timeout) + + {output, _} -> + Logger.warning("[quartz] Failed to send SIGTERM: #{output}") + force_close(port, os_pid) + end + + :ok + end + + # ------------------------------------------------------------------- + # Private functions + # ------------------------------------------------------------------- + + defp wait_for_exit(port, timeout) do + receive do + {^port, {:exit_status, status}} -> + Logger.info("[quartz] Exited with status #{status}") + :ok + after + timeout -> + Logger.warning("[quartz] Shutdown timeout, forcing kill") + {:os_pid, os_pid} = Port.info(port, :os_pid) + force_close(port, os_pid) + end + end + + defp force_close(port, os_pid) do + # Send SIGKILL + System.cmd("kill", ["-KILL", to_string(os_pid)], stderr_to_stdout: true) + + # Close the port + try do + Port.close(port) + rescue + _ -> :ok + end + end end diff --git a/lib/org_garden/server.ex b/lib/org_garden/server.ex new file mode 100644 index 0000000..2ca854b --- /dev/null +++ b/lib/org_garden/server.ex @@ -0,0 +1,212 @@ +defmodule OrgGarden.Server do + @moduledoc """ + Manages the serve mode lifecycle. + + This GenServer encapsulates the full serve mode workflow: + 1. Run initial export pipeline + 2. Start supervised Watcher + Quartz under DynamicSupervisor + 3. Handle graceful shutdown on termination + + ## Usage + + # Start the server (blocks until stopped) + {:ok, pid} = OrgGarden.Server.start_link( + notes_dir: "/path/to/notes", + output_dir: "/path/to/output" + ) + + # Stop gracefully + OrgGarden.Server.stop() + """ + + use GenServer + + require Logger + + alias OrgGarden.Config + + @transforms [OrgGarden.Transforms.Citations] + + # ------------------------------------------------------------------- + # Client API + # ------------------------------------------------------------------- + + @doc """ + Start the serve mode server. + + ## Options + + * `:notes_dir` — path to org-roam notes (required) + * `:output_dir` — output base directory (required) + * `:content_dir` — markdown output directory (default: output_dir/content) + * `:port` — HTTP server port (default: from config) + * `:ws_port` — WebSocket port (default: from config) + """ + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @doc """ + Stop the server gracefully. + """ + def stop(timeout \\ 10_000) do + GenServer.stop(__MODULE__, :normal, timeout) + end + + @doc """ + Check if the server is running. + """ + def running? do + Process.whereis(__MODULE__) != nil + end + + # ------------------------------------------------------------------- + # GenServer callbacks + # ------------------------------------------------------------------- + + @impl true + def init(opts) do + Process.flag(:trap_exit, true) + + notes_dir = Keyword.fetch!(opts, :notes_dir) + output_dir = Keyword.fetch!(opts, :output_dir) + content_dir = Keyword.get(opts, :content_dir, Path.join(output_dir, "content")) + http_port = Keyword.get(opts, :port, Config.get(:http_port, 8080)) + ws_port = Keyword.get(opts, :ws_port, Config.get(:ws_port, 3001)) + + pipeline_opts = Config.pipeline_opts() + + state = %{ + notes_dir: notes_dir, + output_dir: output_dir, + content_dir: content_dir, + pipeline_opts: pipeline_opts, + http_port: http_port, + ws_port: ws_port, + supervisor_pid: nil + } + + # Run initial pipeline synchronously + case run_initial_pipeline(state) do + :ok -> + # Start supervised components + case start_supervisor(state) do + {:ok, sup_pid} -> + Logger.info("Server started on http://localhost:#{http_port}") + Logger.info("Watching #{notes_dir} for changes") + {:ok, %{state | supervisor_pid: sup_pid}} + + {:error, reason} -> + {:stop, reason} + end + + {:error, reason} -> + {:stop, reason} + end + end + + @impl true + def handle_info({:EXIT, pid, reason}, %{supervisor_pid: pid} = state) do + Logger.error("Supervisor exited: #{inspect(reason)}") + {:stop, {:supervisor_exit, reason}, state} + end + + @impl true + def handle_info({:EXIT, _pid, reason}, state) do + Logger.warning("Linked process exited: #{inspect(reason)}") + {:noreply, state} + end + + @impl true + def terminate(reason, state) do + Logger.info("Server shutting down: #{inspect(reason)}") + + if state.supervisor_pid do + # Stop the supervisor gracefully + DynamicSupervisor.terminate_child(OrgGarden.DynamicSupervisor, state.supervisor_pid) + end + + :ok + end + + # ------------------------------------------------------------------- + # Private functions + # ------------------------------------------------------------------- + + defp run_initial_pipeline(state) do + %{ + notes_dir: notes_dir, + output_dir: output_dir, + content_dir: content_dir, + pipeline_opts: pipeline_opts + } = state + + Logger.info("Running initial export pipeline...") + + # Wipe content directory + wipe(content_dir) + + # Export all org files + case OrgGarden.Export.export_all(notes_dir, output_dir) do + {:ok, 0} -> + Logger.warning("No .org files found in #{notes_dir}") + :ok + + {:ok, count} -> + Logger.info("Exported #{count} file(s)") + + # Run transforms + {:ok, stats} = OrgGarden.run(content_dir, @transforms, pipeline_opts) + + Enum.each(stats, fn {mod, c} -> + Logger.info("#{inspect(mod)}: #{c} file(s) modified") + end) + + # Generate index + OrgGarden.Index.generate(content_dir) + :ok + + {:error, failures} -> + Logger.error("Failed to export #{length(failures)} file(s)") + {:error, {:export_failed, failures}} + end + end + + defp start_supervisor(state) do + %{ + notes_dir: notes_dir, + output_dir: output_dir, + content_dir: content_dir, + pipeline_opts: pipeline_opts, + http_port: http_port, + ws_port: ws_port + } = state + + child_spec = { + OrgGarden.Supervisor, + [ + notes_dir: notes_dir, + output_dir: output_dir, + content_dir: content_dir, + pipeline_opts: pipeline_opts, + transforms: @transforms, + port: http_port, + ws_port: ws_port + ] + } + + DynamicSupervisor.start_child(OrgGarden.DynamicSupervisor, child_spec) + end + + defp wipe(content_dir) do + Logger.info("Wiping #{content_dir}") + File.mkdir_p!(content_dir) + + content_dir + |> File.ls!() + |> Enum.reject(&(&1 == ".gitkeep")) + |> Enum.each(fn entry -> + Path.join(content_dir, entry) |> File.rm_rf!() + end) + end +end diff --git a/lib/org_garden/telemetry.ex b/lib/org_garden/telemetry.ex new file mode 100644 index 0000000..18dd99a --- /dev/null +++ b/lib/org_garden/telemetry.ex @@ -0,0 +1,109 @@ +defmodule OrgGarden.Telemetry do + @moduledoc """ + Telemetry event definitions and logging handler. + + ## Events + + The following telemetry events are emitted: + + * `[:org_garden, :export, :start]` — Export of a single file started + - Metadata: `%{file: path}` + + * `[:org_garden, :export, :stop]` — Export of a single file completed + - Measurements: `%{duration: native_time}` + - Metadata: `%{file: path}` + + * `[:org_garden, :export, :exception]` — Export failed + - Measurements: `%{duration: native_time}` + - Metadata: `%{file: path, kind: kind, reason: reason}` + + * `[:org_garden, :watcher, :file_processed]` — File change processed + - Metadata: `%{path: path, event: :created | :modified | :deleted}` + + * `[:org_garden, :server, :start]` — Server started + - Metadata: `%{port: port}` + + * `[:org_garden, :server, :stop]` — Server stopped + - Metadata: `%{reason: reason}` + + ## Usage + + Attach a handler to log events: + + OrgGarden.Telemetry.attach_logger() + + Or use `:telemetry.attach/4` for custom handling. + """ + + require Logger + + @doc """ + Attach a simple logging handler for telemetry events. + """ + def attach_logger do + events = [ + [:org_garden, :export, :stop], + [:org_garden, :export, :exception], + [:org_garden, :watcher, :file_processed], + [:org_garden, :server, :start], + [:org_garden, :server, :stop] + ] + + :telemetry.attach_many( + "org-garden-logger", + events, + &handle_event/4, + nil + ) + end + + @doc """ + Detach the logging handler. + """ + def detach_logger do + :telemetry.detach("org-garden-logger") + end + + # ------------------------------------------------------------------- + # Event handlers + # ------------------------------------------------------------------- + + defp handle_event([:org_garden, :export, :stop], measurements, metadata, _config) do + duration_ms = System.convert_time_unit(measurements.duration, :native, :millisecond) + Logger.debug("Export completed: #{metadata.file} (#{duration_ms}ms)") + end + + defp handle_event([:org_garden, :export, :exception], _measurements, metadata, _config) do + Logger.error("Export failed: #{metadata.file} - #{inspect(metadata.reason)}") + end + + defp handle_event([:org_garden, :watcher, :file_processed], _measurements, metadata, _config) do + Logger.debug("Watcher processed: #{metadata.event} #{metadata.path}") + end + + defp handle_event([:org_garden, :server, :start], _measurements, metadata, _config) do + Logger.info("Server started on port #{metadata.port}") + end + + defp handle_event([:org_garden, :server, :stop], _measurements, metadata, _config) do + Logger.info("Server stopped: #{inspect(metadata.reason)}") + end + + # ------------------------------------------------------------------- + # Convenience functions for emitting events + # ------------------------------------------------------------------- + + @doc """ + Wrap a function with export telemetry events. + """ + def span_export(file, fun) when is_function(fun, 0) do + :telemetry.span( + [:org_garden, :export], + %{file: file}, + fn -> + result = fun.() + {result, %{file: file}} + end + ) + end +end diff --git a/lib/org_garden/watcher.ex b/lib/org_garden/watcher.ex index 4551692..1fb7681 100644 --- a/lib/org_garden/watcher.ex +++ b/lib/org_garden/watcher.ex @@ -29,6 +29,7 @@ defmodule OrgGarden.Watcher do require Logger @debounce_ms 500 + @drain_timeout 5_000 # ------------------------------------------------------------------- # Client API @@ -49,12 +50,21 @@ defmodule OrgGarden.Watcher do GenServer.start_link(__MODULE__, opts, name: __MODULE__) end + @doc """ + Check if the watcher is running. + """ + def running? do + Process.whereis(__MODULE__) != nil + end + # ------------------------------------------------------------------- # GenServer callbacks # ------------------------------------------------------------------- @impl true def init(opts) do + Process.flag(:trap_exit, true) + notes_dir = Keyword.fetch!(opts, :notes_dir) output_dir = Keyword.fetch!(opts, :output_dir) content_dir = Keyword.fetch!(opts, :content_dir) @@ -78,7 +88,8 @@ defmodule OrgGarden.Watcher do pipeline_opts: pipeline_opts, watcher_pid: watcher_pid, initialized_transforms: initialized_transforms, - pending: %{} + pending: %{}, + processing: false }} end @@ -103,7 +114,7 @@ defmodule OrgGarden.Watcher do @impl true def handle_info({:debounced, path, event_type}, state) do - state = %{state | pending: Map.delete(state.pending, path)} + state = %{state | pending: Map.delete(state.pending, path), processing: true} case event_type do :deleted -> @@ -113,12 +124,27 @@ defmodule OrgGarden.Watcher do handle_change(path, state) end - {:noreply, state} + emit_telemetry(:file_processed, %{path: path, event: event_type}) + + {:noreply, %{state | processing: false}} end @impl true - def terminate(_reason, state) do + def terminate(reason, state) do + Logger.info("Watcher: shutting down (#{inspect(reason)})") + + # Drain pending timers + drain_pending(state.pending) + + # Wait for any in-flight processing + if state.processing do + Logger.info("Watcher: waiting for in-flight processing...") + Process.sleep(100) + end + + # Teardown transforms OrgGarden.teardown_transforms(state.initialized_transforms) + :ok end @@ -136,20 +162,25 @@ defmodule OrgGarden.Watcher do } = state md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir) - IO.puts("==> Changed: #{Path.relative_to(orgfile, notes_dir)}") + Logger.info("Changed: #{Path.relative_to(orgfile, notes_dir)}") + + start_time = System.monotonic_time(:millisecond) case OrgGarden.Export.export_file(orgfile, notes_dir, output_dir) do {:ok, _} -> - IO.puts(" exported: #{Path.relative_to(md_path, content_dir)}") + Logger.info(" exported: #{Path.relative_to(md_path, content_dir)}") - {:ok, stats} = OrgGarden.run_on_files_with([md_path], initialized_transforms, pipeline_opts) + {:ok, stats} = + OrgGarden.run_on_files_with([md_path], initialized_transforms, pipeline_opts) Enum.each(stats, fn {mod, count} -> - if count > 0, do: IO.puts(" #{inspect(mod)}: #{count} file(s) modified") + if count > 0, do: Logger.info(" #{inspect(mod)}: #{count} file(s) modified") end) regenerate_index(content_dir) - IO.puts("==> Done") + + duration = System.monotonic_time(:millisecond) - start_time + Logger.info(" done in #{duration}ms") {:error, reason} -> Logger.error("Watcher: export failed for #{orgfile}: #{inspect(reason)}") @@ -160,18 +191,17 @@ defmodule OrgGarden.Watcher do %{notes_dir: notes_dir, content_dir: content_dir} = state md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir) - IO.puts("==> Deleted: #{Path.relative_to(orgfile, notes_dir)}") + Logger.info("Deleted: #{Path.relative_to(orgfile, notes_dir)}") if File.exists?(md_path) do File.rm!(md_path) - IO.puts(" removed: #{Path.relative_to(md_path, content_dir)}") + Logger.info(" removed: #{Path.relative_to(md_path, content_dir)}") # Clean up empty parent directories left behind cleanup_empty_dirs(Path.dirname(md_path), content_dir) end regenerate_index(content_dir) - IO.puts("==> Done") end # ------------------------------------------------------------------- @@ -197,6 +227,25 @@ defmodule OrgGarden.Watcher do %{state | pending: Map.put(state.pending, path, ref)} end + defp drain_pending(pending) when map_size(pending) == 0, do: :ok + + defp drain_pending(pending) do + Logger.info("Watcher: draining #{map_size(pending)} pending event(s)") + + # Cancel all timers and log what we're dropping + Enum.each(pending, fn {path, ref} -> + Process.cancel_timer(ref) + Logger.debug("Watcher: dropped pending event for #{path}") + end) + + # Give a moment for any final messages to arrive + receive do + {:debounced, _, _} -> :ok + after + @drain_timeout -> :ok + end + end + defp org_file?(path), do: String.ends_with?(path, ".org") defp temporary_file?(path) do @@ -233,4 +282,12 @@ defmodule OrgGarden.Watcher do end end end + + # ------------------------------------------------------------------- + # Telemetry + # ------------------------------------------------------------------- + + defp emit_telemetry(event, metadata) do + :telemetry.execute([:org_garden, :watcher, event], %{}, metadata) + end end diff --git a/mix.exs b/mix.exs index 6689d37..475bd71 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,8 @@ defmodule OrgGarden.MixProject do elixir: "~> 1.17", start_permanent: Mix.env() == :prod, deps: deps(), - escript: escript() + escript: escript(), + releases: releases() ] end @@ -23,12 +24,27 @@ defmodule OrgGarden.MixProject do [main_module: OrgGarden.CLI] end + defp releases do + [ + org_garden: [ + include_executables_for: [:unix], + applications: [runtime_tools: :permanent], + steps: [:assemble, :tar] + ] + ] + end + defp deps do [ {:finch, "~> 0.19"}, {:req, "~> 0.5"}, {:jason, "~> 1.4"}, - {:file_system, "~> 1.0"} + {:file_system, "~> 1.0"}, + # Health check HTTP server + {:bandit, "~> 1.0"}, + {:plug, "~> 1.15"}, + # Telemetry + {:telemetry, "~> 1.0"} ] end end diff --git a/mix.lock b/mix.lock index c5e4b01..b2c653c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, @@ -7,6 +8,10 @@ "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, } diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..936970d --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,148 @@ +{ config, lib, pkgs, ... }: + +let + cfg = config.services.org-garden; +in +{ + options.services.org-garden = { + enable = lib.mkEnableOption "org-garden publishing service"; + + package = lib.mkOption { + type = lib.types.package; + description = "The org-garden package to use."; + }; + + notesDir = lib.mkOption { + type = lib.types.path; + description = "Path to org-roam notes directory."; + }; + + outputDir = lib.mkOption { + type = lib.types.path; + default = "/var/lib/org-garden"; + description = "Output directory for generated content."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8080; + description = "HTTP server port."; + }; + + wsPort = lib.mkOption { + type = lib.types.port; + default = 3001; + description = "WebSocket hot reload port."; + }; + + healthPort = lib.mkOption { + type = lib.types.port; + default = 9090; + description = "Health check endpoint port."; + }; + + zoteroUrl = lib.mkOption { + type = lib.types.str; + default = "http://localhost:23119"; + description = "Zotero Better BibTeX URL."; + }; + + bibtexFilePath = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = "Path to fallback BibTeX file."; + }; + + citationMode = lib.mkOption { + type = lib.types.enum [ "silent" "warn" "strict" ]; + default = "warn"; + description = "Citation resolution failure mode."; + }; + + user = lib.mkOption { + type = lib.types.str; + default = "org-garden"; + description = "User to run the service as."; + }; + + group = lib.mkOption { + type = lib.types.str; + default = "org-garden"; + description = "Group to run the service as."; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open the firewall for the HTTP port."; + }; + }; + + config = lib.mkIf cfg.enable { + users.users.${cfg.user} = { + isSystemUser = true; + group = cfg.group; + home = cfg.outputDir; + createHome = true; + }; + + users.groups.${cfg.group} = { }; + + systemd.services.org-garden = { + description = "Org-Garden Publishing Service"; + documentation = [ "https://github.com/ignacio.ballesteros/org-garden" ]; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + environment = { + NOTES_DIR = toString cfg.notesDir; + OUTPUT_DIR = cfg.outputDir; + PORT = toString cfg.port; + WS_PORT = toString cfg.wsPort; + HEALTH_PORT = toString cfg.healthPort; + ZOTERO_URL = cfg.zoteroUrl; + CITATION_MODE = cfg.citationMode; + } // lib.optionalAttrs (cfg.bibtexFile != null) { + BIBTEX_FILE = toString cfg.bibtexFile; + }; + + serviceConfig = { + Type = "exec"; + ExecStart = "${cfg.package}/bin/org-garden serve"; + Restart = "on-failure"; + RestartSec = 5; + + # Directories + StateDirectory = "org-garden"; + WorkingDirectory = cfg.outputDir; + + # User/Group + User = cfg.user; + Group = cfg.group; + + # Hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + ReadWritePaths = [ cfg.outputDir ]; + ReadOnlyPaths = [ cfg.notesDir ]; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = false; # Required for BEAM JIT + RestrictRealtime = true; + RestrictSUIDSGID = true; + RemoveIPC = true; + }; + }; + + networking.firewall = lib.mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.port ]; + }; + }; +} diff --git a/rel/env.sh.eex b/rel/env.sh.eex new file mode 100644 index 0000000..332003c --- /dev/null +++ b/rel/env.sh.eex @@ -0,0 +1,16 @@ +#!/bin/sh + +# Release environment configuration +# This file is evaluated before the release starts + +# Disable Erlang distribution (not needed for this application) +export RELEASE_DISTRIBUTION=none + +# Enable shell history in IEx +export ERL_AFLAGS="-kernel shell_history enabled" + +# Set release node name +export RELEASE_NODE=org_garden + +# Log to console by default +export ELIXIR_ERL_OPTIONS="-noshell" diff --git a/rel/overlays/bin/org-garden b/rel/overlays/bin/org-garden new file mode 100755 index 0000000..dbdbaab --- /dev/null +++ b/rel/overlays/bin/org-garden @@ -0,0 +1,74 @@ +#!/bin/sh +set -e + +# org-garden CLI wrapper +# Routes commands to the appropriate release entry point + +SELF=$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0") +RELEASE_ROOT=$(cd "$(dirname "$SELF")/.." && pwd) + +usage() { + cat < [options] + +Commands: + serve Start development server (long-running) + build Build static site (one-shot) + export Export org to markdown (one-shot) + +Release commands: + daemon Start as background daemon + daemon_iex Start daemon with IEx attached + stop Stop running daemon + restart Restart running daemon + remote Connect to running instance + pid Print PID of running instance + version Print release version + +Options: + --port HTTP server port (default: 8080) + --ws-port WebSocket port (default: 3001) + --output Output directory + --watch Watch mode for export command + +Environment: + NOTES_DIR Default notes directory + OUTPUT_DIR Default output directory + QUARTZ_PATH Path to Quartz installation + ZOTERO_URL Zotero Better BibTeX URL + BIBTEX_FILE Fallback BibTeX file + CITATION_MODE silent | warn | strict +EOF +} + +case "${1:-}" in + serve) + shift + exec "$RELEASE_ROOT/bin/org_garden" start -- "$@" + ;; + build) + shift + exec "$RELEASE_ROOT/bin/org_garden" eval "OrgGarden.CLI.main([\"build\" | System.argv()])" -- "$@" + ;; + export) + shift + exec "$RELEASE_ROOT/bin/org_garden" eval "OrgGarden.CLI.main([\"export\" | System.argv()])" -- "$@" + ;; + daemon|daemon_iex|stop|restart|remote|pid|version|start|start_iex|eval|rpc) + exec "$RELEASE_ROOT/bin/org_garden" "$@" + ;; + -h|--help|help) + usage + exit 0 + ;; + "") + usage + exit 1 + ;; + *) + echo "Unknown command: $1" >&2 + echo "" >&2 + usage >&2 + exit 1 + ;; +esac