defmodule OrgGarden.CLI do @moduledoc """ Escript entry point for the org-garden pipeline. ## Commands org-garden serve [--port 8080] [--ws-port 3001] org-garden build [--output ] org-garden export [--watch] ### serve Development server with watch + live reload. Starts both the org→md watcher and Quartz in serve mode. ### build One-shot build for CI/production. Exports org files, runs transforms, then builds static site with Quartz. ### export Just export org→md (current pipeline behavior). Use --watch for incremental re-export on file changes. ## Arguments notes-dir Path to the directory containing `.org` notes (required). Also accepts the `NOTES_DIR` env var. ## Options --output Output root directory (used as ox-hugo base dir). Defaults to the `OUTPUT_DIR` env var, or the current working directory. --content-dir

Output directory for exported Markdown. Defaults to `/content`. --port HTTP server port (default: 8080). Only for `serve`. --ws-port WebSocket hot reload port (default: 3001). Only for `serve`. --watch After initial batch, watch notes-dir for changes and incrementally re-export affected files. Only for `export`. ## Environment Variables BIBTEX_FILE Path to a `.bib` file used as citation fallback. ZOTERO_URL Zotero Better BibTeX base URL (default: http://localhost:23119). CITATION_MODE silent | warn (default) | strict. QUARTZ_PATH Path to quartz directory (required for serve/build). NODE_PATH Node.js executable (default: node). """ require Logger alias OrgGarden.Config @transforms [OrgGarden.Transforms.Citations] def main(argv) do Application.ensure_all_started(:org_garden) case argv do ["serve" | rest] -> handle_serve(rest) ["build" | rest] -> handle_build(rest) ["export" | rest] -> handle_export(rest) # Legacy: treat bare args as export command for backward compatibility [_ | _] -> handle_export(argv) _ -> abort("Usage: org-garden [options]") end end # --------------------------------------------------------------------------- # Command: serve # --------------------------------------------------------------------------- def handle_serve(argv) do require_quartz_env() {notes_dir, output_dir, content_dir, opts} = parse_serve_args(argv) IO.puts("==> Starting development server...") server_opts = [ notes_dir: notes_dir, output_dir: output_dir, content_dir: content_dir ] |> maybe_put(:port, opts[:port]) |> maybe_put(:ws_port, opts[:ws_port]) case OrgGarden.Server.start_link(server_opts) 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)") # Wait for server to exit ref = Process.monitor(pid) 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 {opts, positional, _invalid} = OptionParser.parse(argv, strict: [ output: :string, content_dir: :string, port: :integer, ws_port: :integer ] ) notes_dir = extract_notes_dir(positional, "serve") output_dir = extract_output_dir(opts) content_dir = extract_content_dir(opts, output_dir) {notes_dir, output_dir, content_dir, opts} end # --------------------------------------------------------------------------- # Command: build # --------------------------------------------------------------------------- def handle_build(argv) do quartz_path = require_quartz_env() {notes_dir, output_dir, content_dir, _opts} = parse_build_args(argv) pipeline_opts = Config.pipeline_opts() # Full batch export wipe(content_dir) export_result = export_all(notes_dir, output_dir) run_pipeline(content_dir, pipeline_opts) generate_index(content_dir) # Track if we had export failures had_export_failures = match?({:error, _}, export_result) node_path = Config.get(:node_path, "node") IO.puts("==> Building static site with Quartz...") {output, status} = System.cmd( node_path, [ Path.join(quartz_path, "quartz/bootstrap-cli.mjs"), "build", "--directory", content_dir, "--output", Path.join(output_dir, "public") ], cd: quartz_path, stderr_to_stdout: true ) IO.puts(output) if status != 0 do abort("Quartz build failed with status #{status}") end IO.puts("==> Build complete. Output: #{Path.join(output_dir, "public")}") # Exit with error if there were export failures if had_export_failures do System.halt(1) end end defp parse_build_args(argv) do {opts, positional, _invalid} = OptionParser.parse(argv, strict: [output: :string, content_dir: :string] ) notes_dir = extract_notes_dir(positional, "build") output_dir = extract_output_dir(opts) content_dir = extract_content_dir(opts, output_dir) {notes_dir, output_dir, content_dir, opts} end # --------------------------------------------------------------------------- # Command: export (original pipeline behavior) # --------------------------------------------------------------------------- def handle_export(argv) do {notes_dir, output_dir, content_dir, watch?} = parse_export_args(argv) pipeline_opts = Config.pipeline_opts() # Phase 1-4: full batch export wipe(content_dir) export_result = export_all(notes_dir, output_dir) run_pipeline(content_dir, pipeline_opts) generate_index(content_dir) md_count = content_dir |> Path.join("**/*.md") |> Path.wildcard() |> length() IO.puts("==> Done. #{md_count} markdown files in #{content_dir}") # Exit with error if there were export failures (unless in watch mode) case {export_result, watch?} do {{:error, _}, false} -> System.halt(1) _ -> :ok end # Phase 5: optional watch mode if watch? do IO.puts("==> Watching #{notes_dir} for .org changes... (Ctrl+C to stop)") {: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} ) # 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 defp parse_export_args(argv) do {opts, positional, _invalid} = OptionParser.parse(argv, strict: [output: :string, content_dir: :string, watch: :boolean] ) notes_dir = extract_notes_dir(positional, "export") output_dir = extract_output_dir(opts) content_dir = extract_content_dir(opts, output_dir) watch? = Keyword.get(opts, :watch, false) {notes_dir, output_dir, content_dir, watch?} end # --------------------------------------------------------------------------- # Shared argument extraction # --------------------------------------------------------------------------- defp extract_notes_dir(positional, command) do notes_dir = case positional do [dir | _] -> dir [] -> Config.get(:notes_dir) || abort("Usage: org-garden #{command} [options]") end notes_dir = Path.expand(notes_dir) unless File.dir?(notes_dir) do abort("Error: notes directory does not exist: #{notes_dir}") end notes_dir end defp extract_output_dir(opts) do (opts[:output] || Config.get(:output_dir) || File.cwd!()) |> Path.expand() end defp extract_content_dir(opts, output_dir) do (opts[:content_dir] || Path.join(output_dir, "content")) |> Path.expand() end # --------------------------------------------------------------------------- # Phase 1: Wipe content/ # --------------------------------------------------------------------------- defp wipe(content_dir) do IO.puts("==> 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 # --------------------------------------------------------------------------- # Phase 2: Export org files via Emacs + ox-hugo # --------------------------------------------------------------------------- defp export_all(notes_dir, output_dir) do IO.puts("==> Exporting org files from #{notes_dir}") case OrgGarden.Export.export_all(notes_dir, output_dir) do {:ok, 0, []} -> IO.puts(" no .org files found") :ok {:ok, count, []} -> IO.puts(" exported #{count} file(s)") :ok {:ok, count, failures} -> IO.puts(" exported #{count} file(s), #{length(failures)} failed") Enum.each(failures, fn {f, {:error, {:emacs_exit, code}}} -> IO.puts(:stderr, " failed: #{f} (emacs exit code #{code})") end) {:error, length(failures)} end end # --------------------------------------------------------------------------- # Phase 3: Markdown transformation pipeline # --------------------------------------------------------------------------- defp run_pipeline(content_dir, pipeline_opts) do IO.puts("==> Running markdown pipeline") {:ok, stats} = OrgGarden.run(content_dir, @transforms, pipeline_opts) Enum.each(stats, fn {mod, count} -> IO.puts(" #{inspect(mod)}: #{count} file(s) modified") end) end # --------------------------------------------------------------------------- # Phase 4: Generate default index.md if none was exported # --------------------------------------------------------------------------- defp generate_index(content_dir) do IO.puts("==> Generating index") OrgGarden.Index.generate(content_dir) end # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- defp require_quartz_env do case Config.get(:quartz_path) do nil -> abort(""" Error: QUARTZ_PATH environment variable not set. The 'serve' and 'build' commands require Quartz to be available. Use the wrapper scripts that set up the environment: nix run .#notes -- # for serve nix run .#build -- # for build Or set QUARTZ_PATH manually to point to a quartz-org-roam checkout with node_modules installed. For export-only mode (no Quartz), use: org-garden export [--watch] """) path -> unless File.exists?(Path.join(path, "quartz/bootstrap-cli.mjs")) do abort("Error: QUARTZ_PATH=#{path} does not contain quartz/bootstrap-cli.mjs") end path end end defp abort(message) do IO.puts(:stderr, message) System.halt(1) end defp maybe_put(keyword, _key, nil), do: keyword defp maybe_put(keyword, key, value), do: Keyword.put(keyword, key, value) end