Add service infrastructure for long-running deployment

- Add configuration system (config/*.exs, OrgGarden.Config)
- Refactor supervision tree with DynamicSupervisor and Registry
- Add OrgGarden.Server for serve mode lifecycle management
- Add health check HTTP endpoints (Bandit/Plug on :9090)
- Add telemetry events for export and watcher operations
- Implement graceful shutdown with SIGTERM handling
- Add Mix Release support with overlay scripts
- Add NixOS module for systemd service deployment
- Update documentation with service usage
This commit is contained in:
Ignacio Ballesteros
2026-02-21 20:38:47 +01:00
parent 6476b45f04
commit 01805dbf39
23 changed files with 1147 additions and 83 deletions

3
.formatter.exs Normal file
View File

@@ -0,0 +1,3 @@
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]

2
.gitignore vendored
View File

@@ -9,7 +9,7 @@
erl_crash.dump erl_crash.dump
*.tar *.tar
/tmp/ /tmp/
org_garden /org_garden
# Nix # Nix
result result

View File

@@ -7,7 +7,7 @@ An [[https://orgmode.org/][org-roam]] to static website publishing pipeline. Con
#+begin_example #+begin_example
org-garden serve <notes-dir> # dev server with live reload org-garden serve <notes-dir> # dev server with live reload
org-garden build <notes-dir> # production static build org-garden build <notes-dir> # production static build
org-garden export <notes-dir> # org markdown only org-garden export <notes-dir> # org -> markdown only
#+end_example #+end_example
* Running with Nix (recommended) * Running with Nix (recommended)
@@ -26,3 +26,52 @@ mix escript.build
#+end_src #+end_src
Requires =QUARTZ_PATH= to point to a Quartz install with =node_modules= for =serve= and =build= commands. 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 |

18
config/config.exs Normal file
View File

@@ -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"

4
config/dev.exs Normal file
View File

@@ -0,0 +1,4 @@
import Config
# Development-specific configuration
config :logger, level: :debug

6
config/prod.exs Normal file
View File

@@ -0,0 +1,6 @@
import Config
# Production-specific configuration
# Most config comes from runtime.exs via environment variables
config :logger, level: :info

42
config/runtime.exs Normal file
View File

@@ -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)

4
config/test.exs Normal file
View File

@@ -0,0 +1,4 @@
import Config
# Test-specific configuration
config :logger, level: :warning

View File

@@ -7,6 +7,15 @@
}; };
outputs = { self, nixpkgs, flake-utils }: 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: flake-utils.lib.eachDefaultSystem (system:
let let
pkgs = import nixpkgs { inherit system; }; pkgs = import nixpkgs { inherit system; };
@@ -47,6 +56,8 @@
./mix.exs ./mix.exs
./mix.lock ./mix.lock
./lib ./lib
./config
./rel
]; ];
}; };
escriptBinName = "org_garden"; escriptBinName = "org_garden";

View File

@@ -1,14 +1,39 @@
defmodule OrgGarden.Application do 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 use Application
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
children = [ 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] opts = [strategy: :one_for_one, name: OrgGarden.AppSupervisor]
Supervisor.start_link(children, opts) Supervisor.start_link(children, opts)
end end
defp health_port do
Application.get_env(:org_garden, :health_port, 9090)
end
end end

View File

@@ -48,6 +48,8 @@ defmodule OrgGarden.CLI do
require Logger require Logger
alias OrgGarden.Config
@transforms [OrgGarden.Transforms.Citations] @transforms [OrgGarden.Transforms.Citations]
def main(argv) do def main(argv) do
@@ -70,31 +72,36 @@ defmodule OrgGarden.CLI do
def handle_serve(argv) do def handle_serve(argv) do
require_quartz_env() require_quartz_env()
{notes_dir, output_dir, content_dir, opts} = parse_serve_args(argv) {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...") IO.puts("==> Starting development server...")
{:ok, _pid} = case OrgGarden.Server.start_link(
OrgGarden.Supervisor.start_link( notes_dir: notes_dir,
notes_dir: notes_dir, output_dir: output_dir,
output_dir: output_dir, content_dir: content_dir,
content_dir: content_dir, port: opts[:port],
pipeline_opts: pipeline_opts, ws_port: opts[:ws_port]
transforms: @transforms, ) do
port: opts[:port] || 8080, {:ok, pid} ->
ws_port: opts[:ws_port] || 3001 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}") # Wait for server to exit
IO.puts("==> Watching #{notes_dir} for changes (Ctrl+C to stop)") 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 end
defp parse_serve_args(argv) do defp parse_serve_args(argv) do
@@ -122,7 +129,7 @@ defmodule OrgGarden.CLI do
def handle_build(argv) do def handle_build(argv) do
quartz_path = require_quartz_env() quartz_path = require_quartz_env()
{notes_dir, output_dir, content_dir, _opts} = parse_build_args(argv) {notes_dir, output_dir, content_dir, _opts} = parse_build_args(argv)
pipeline_opts = build_pipeline_opts() pipeline_opts = Config.pipeline_opts()
# Full batch export # Full batch export
wipe(content_dir) wipe(content_dir)
@@ -130,7 +137,7 @@ defmodule OrgGarden.CLI do
run_pipeline(content_dir, pipeline_opts) run_pipeline(content_dir, pipeline_opts)
generate_index(content_dir) 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...") IO.puts("==> Building static site with Quartz...")
@@ -177,7 +184,7 @@ defmodule OrgGarden.CLI do
def handle_export(argv) do def handle_export(argv) do
{notes_dir, output_dir, content_dir, watch?} = parse_export_args(argv) {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 # Phase 1-4: full batch export
wipe(content_dir) wipe(content_dir)
@@ -197,16 +204,29 @@ defmodule OrgGarden.CLI do
if watch? do if watch? do
IO.puts("==> Watching #{notes_dir} for .org changes... (Ctrl+C to stop)") IO.puts("==> Watching #{notes_dir} for .org changes... (Ctrl+C to stop)")
{:ok, _pid} = {:ok, pid} =
OrgGarden.Watcher.start_link( DynamicSupervisor.start_child(
notes_dir: notes_dir, OrgGarden.DynamicSupervisor,
output_dir: output_dir, {OrgGarden.Watcher,
content_dir: content_dir, notes_dir: notes_dir,
pipeline_opts: pipeline_opts, output_dir: output_dir,
transforms: @transforms 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
end end
@@ -235,7 +255,7 @@ defmodule OrgGarden.CLI do
dir dir
[] -> [] ->
System.get_env("NOTES_DIR") || Config.get(:notes_dir) ||
abort("Usage: org-garden #{command} <notes-dir> [options]") abort("Usage: org-garden #{command} <notes-dir> [options]")
end end
@@ -249,7 +269,7 @@ defmodule OrgGarden.CLI do
end end
defp extract_output_dir(opts) do 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() |> Path.expand()
end end
@@ -328,7 +348,7 @@ defmodule OrgGarden.CLI do
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
defp require_quartz_env do defp require_quartz_env do
case System.get_env("QUARTZ_PATH") do case Config.get(:quartz_path) do
nil -> nil ->
abort(""" abort("""
Error: QUARTZ_PATH environment variable not set. Error: QUARTZ_PATH environment variable not set.
@@ -355,19 +375,6 @@ defmodule OrgGarden.CLI do
end end
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 defp abort(message) do
IO.puts(:stderr, message) IO.puts(:stderr, message)
System.halt(1) System.halt(1)

98
lib/org_garden/config.ex Normal file
View File

@@ -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

View File

@@ -16,6 +16,12 @@ defmodule OrgGarden.Export do
""" """
@spec export_file(String.t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()} @spec export_file(String.t(), String.t(), String.t()) :: {:ok, non_neg_integer()} | {:error, term()}
def export_file(orgfile, notes_dir, output_dir) do 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 = section =
orgfile orgfile
|> Path.dirname() |> Path.dirname()

89
lib/org_garden/health.ex Normal file
View File

@@ -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

View File

@@ -16,7 +16,11 @@ defmodule OrgGarden.Quartz do
require Logger 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 # Client API
@@ -35,21 +39,27 @@ defmodule OrgGarden.Quartz do
GenServer.start_link(__MODULE__, opts, name: __MODULE__) GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end end
@doc """
Check if Quartz is running.
"""
def running? do
Process.whereis(__MODULE__) != nil
end
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# GenServer callbacks # GenServer callbacks
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@impl true @impl true
def init(opts) do def init(opts) do
quartz_path = Process.flag(:trap_exit, true)
System.get_env("QUARTZ_PATH") ||
raise "QUARTZ_PATH environment variable not set"
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) content_dir = Keyword.fetch!(opts, :content_dir)
http_port = Keyword.get(opts, :port, 8080) http_port = Keyword.get(opts, :port, Config.get(:http_port, 8080))
ws_port = Keyword.get(opts, :ws_port, 3001) ws_port = Keyword.get(opts, :ws_port, Config.get(:ws_port, 3001))
cli_path = Path.join(quartz_path, "quartz/bootstrap-cli.mjs") cli_path = Path.join(quartz_path, "quartz/bootstrap-cli.mjs")
@@ -61,9 +71,12 @@ defmodule OrgGarden.Quartz do
cli_path, cli_path,
"build", "build",
"--serve", "--serve",
"--directory", content_dir, "--directory",
"--port", to_string(http_port), content_dir,
"--wsPort", to_string(ws_port) "--port",
to_string(http_port),
"--wsPort",
to_string(ws_port)
] ]
Logger.info("[quartz] Starting: #{node_path} #{Enum.join(args, " ")}") Logger.info("[quartz] Starting: #{node_path} #{Enum.join(args, " ")}")
@@ -79,8 +92,12 @@ defmodule OrgGarden.Quartz do
env: [{~c"NODE_NO_WARNINGS", ~c"1"}] 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__{ state = %__MODULE__{
port: port, port: port,
os_pid: os_pid,
quartz_path: quartz_path, quartz_path: quartz_path,
content_dir: content_dir, content_dir: content_dir,
http_port: http_port, http_port: http_port,
@@ -102,17 +119,65 @@ defmodule OrgGarden.Quartz do
@impl true @impl true
def handle_info({port, {:exit_status, status}}, %{port: port} = state) do def handle_info({port, {:exit_status, status}}, %{port: port} = state) do
Logger.error("[quartz] Process exited with status #{status}") Logger.error("[quartz] Process exited with status #{status}")
{:stop, {:quartz_exit, status}, state} {:stop, {:quartz_exit, status}, %{state | port: nil, os_pid: nil}}
end end
@impl true @impl true
def terminate(_reason, %{port: port}) when is_port(port) do def handle_info({:EXIT, port, reason}, %{port: port} = state) do
# Attempt graceful shutdown Logger.warning("[quartz] Port terminated: #{inspect(reason)}")
Port.close(port) {:stop, {:port_exit, reason}, %{state | port: nil, os_pid: nil}}
:ok
rescue
_ -> :ok
end 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 end

212
lib/org_garden/server.ex Normal file
View File

@@ -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

109
lib/org_garden/telemetry.ex Normal file
View File

@@ -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

View File

@@ -29,6 +29,7 @@ defmodule OrgGarden.Watcher do
require Logger require Logger
@debounce_ms 500 @debounce_ms 500
@drain_timeout 5_000
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# Client API # Client API
@@ -49,12 +50,21 @@ defmodule OrgGarden.Watcher do
GenServer.start_link(__MODULE__, opts, name: __MODULE__) GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end end
@doc """
Check if the watcher is running.
"""
def running? do
Process.whereis(__MODULE__) != nil
end
# ------------------------------------------------------------------- # -------------------------------------------------------------------
# GenServer callbacks # GenServer callbacks
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@impl true @impl true
def init(opts) do def init(opts) do
Process.flag(:trap_exit, true)
notes_dir = Keyword.fetch!(opts, :notes_dir) notes_dir = Keyword.fetch!(opts, :notes_dir)
output_dir = Keyword.fetch!(opts, :output_dir) output_dir = Keyword.fetch!(opts, :output_dir)
content_dir = Keyword.fetch!(opts, :content_dir) content_dir = Keyword.fetch!(opts, :content_dir)
@@ -78,7 +88,8 @@ defmodule OrgGarden.Watcher do
pipeline_opts: pipeline_opts, pipeline_opts: pipeline_opts,
watcher_pid: watcher_pid, watcher_pid: watcher_pid,
initialized_transforms: initialized_transforms, initialized_transforms: initialized_transforms,
pending: %{} pending: %{},
processing: false
}} }}
end end
@@ -103,7 +114,7 @@ defmodule OrgGarden.Watcher do
@impl true @impl true
def handle_info({:debounced, path, event_type}, state) do 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 case event_type do
:deleted -> :deleted ->
@@ -113,12 +124,27 @@ defmodule OrgGarden.Watcher do
handle_change(path, state) handle_change(path, state)
end end
{:noreply, state} emit_telemetry(:file_processed, %{path: path, event: event_type})
{:noreply, %{state | processing: false}}
end end
@impl true @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) OrgGarden.teardown_transforms(state.initialized_transforms)
:ok :ok
end end
@@ -136,20 +162,25 @@ defmodule OrgGarden.Watcher do
} = state } = state
md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir) 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 case OrgGarden.Export.export_file(orgfile, notes_dir, output_dir) do
{:ok, _} -> {: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} -> 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) end)
regenerate_index(content_dir) regenerate_index(content_dir)
IO.puts("==> Done")
duration = System.monotonic_time(:millisecond) - start_time
Logger.info(" done in #{duration}ms")
{:error, reason} -> {:error, reason} ->
Logger.error("Watcher: export failed for #{orgfile}: #{inspect(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 %{notes_dir: notes_dir, content_dir: content_dir} = state
md_path = OrgGarden.Export.expected_md_path(orgfile, notes_dir, content_dir) 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 if File.exists?(md_path) do
File.rm!(md_path) 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 # Clean up empty parent directories left behind
cleanup_empty_dirs(Path.dirname(md_path), content_dir) cleanup_empty_dirs(Path.dirname(md_path), content_dir)
end end
regenerate_index(content_dir) regenerate_index(content_dir)
IO.puts("==> Done")
end end
# ------------------------------------------------------------------- # -------------------------------------------------------------------
@@ -197,6 +227,25 @@ defmodule OrgGarden.Watcher do
%{state | pending: Map.put(state.pending, path, ref)} %{state | pending: Map.put(state.pending, path, ref)}
end 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 org_file?(path), do: String.ends_with?(path, ".org")
defp temporary_file?(path) do defp temporary_file?(path) do
@@ -233,4 +282,12 @@ defmodule OrgGarden.Watcher do
end end
end end
end end
# -------------------------------------------------------------------
# Telemetry
# -------------------------------------------------------------------
defp emit_telemetry(event, metadata) do
:telemetry.execute([:org_garden, :watcher, event], %{}, metadata)
end
end end

20
mix.exs
View File

@@ -8,7 +8,8 @@ defmodule OrgGarden.MixProject do
elixir: "~> 1.17", elixir: "~> 1.17",
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,
deps: deps(), deps: deps(),
escript: escript() escript: escript(),
releases: releases()
] ]
end end
@@ -23,12 +24,27 @@ defmodule OrgGarden.MixProject do
[main_module: OrgGarden.CLI] [main_module: OrgGarden.CLI]
end end
defp releases do
[
org_garden: [
include_executables_for: [:unix],
applications: [runtime_tools: :permanent],
steps: [:assemble, :tar]
]
]
end
defp deps do defp deps do
[ [
{:finch, "~> 0.19"}, {:finch, "~> 0.19"},
{:req, "~> 0.5"}, {:req, "~> 0.5"},
{:jason, "~> 1.4"}, {: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
end end

View File

@@ -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"}, "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"}, "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"}, "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"}, "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_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "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"}, "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"}, "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"},
} }

148
nix/module.nix Normal file
View File

@@ -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 ];
};
};
}

16
rel/env.sh.eex Normal file
View File

@@ -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"

74
rel/overlays/bin/org-garden Executable file
View File

@@ -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 <<EOF
Usage: org-garden <command> [options]
Commands:
serve <notes-dir> Start development server (long-running)
build <notes-dir> Build static site (one-shot)
export <notes-dir> 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 <n> HTTP server port (default: 8080)
--ws-port <n> WebSocket port (default: 3001)
--output <path> 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