From e7b8768a8f0129bed1ee3b687610a73c6bc09b63 Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Sun, 3 May 2026 00:54:29 -0400 Subject: [PATCH 1/8] Remove broken `bates start` subcommand PR #31 added a `bates start` subcommand to the existing escript, but it crashed immediately because escripts cannot carry `erlexec`'s `priv/exec-port` C binary. The escript packaging format is the wrong tool for the daemon. This removes `Bates.CLI.Start`, the dispatcher clause that routed `start` to it, the `bates start` line in the usage banner, and the test file. The matching usage assertion in `Bates.CLITest` is updated. A subsequent commit will reintroduce the daemon as a Mix release named `batesd` so `erlexec`'s priv directory is available at runtime. --- source/lib/bates/cli.ex | 2 - source/lib/bates/cli/start.ex | 88 ---------------------------- source/test/bates/cli/start_test.exs | 57 ------------------ source/test/bates/cli_test.exs | 1 - 4 files changed, 148 deletions(-) delete mode 100644 source/lib/bates/cli/start.ex delete mode 100644 source/test/bates/cli/start_test.exs diff --git a/source/lib/bates/cli.ex b/source/lib/bates/cli.ex index 6861162..84d0630 100644 --- a/source/lib/bates/cli.ex +++ b/source/lib/bates/cli.ex @@ -9,7 +9,6 @@ defmodule Bates.CLI do end @doc false - def dispatch(["start" | rest]), do: Bates.CLI.Start.run(rest) def dispatch(["setup"]), do: Bates.CLI.Setup.run() def dispatch(["status"]), do: Bates.CLI.Status.run() def dispatch(["up", name]) when is_binary(name), do: Bates.CLI.Up.run(name) @@ -26,7 +25,6 @@ defmodule Bates.CLI do defp usage do IO.write(:stderr, """ Usage: - bates start [--config ] bates setup bates status bates up diff --git a/source/lib/bates/cli/start.ex b/source/lib/bates/cli/start.ex deleted file mode 100644 index 3b951c9..0000000 --- a/source/lib/bates/cli/start.ex +++ /dev/null @@ -1,88 +0,0 @@ -defmodule Bates.CLI.Start do - @moduledoc false - - alias Bates.CLI.Client - - @usage "Usage: bates start [--config ]\n" - - def run(argv) when is_list(argv) do - case parse(argv) do - {:ok, opts} -> - do_run(opts) - - {:error, message} -> - IO.write(:stderr, message <> "\n" <> @usage) - 2 - end - end - - defp parse(argv) do - case OptionParser.parse(argv, strict: [config: :string]) do - {opts, [], []} -> - {:ok, opts} - - {_opts, [extra | _], _} -> - {:error, "bates start: unexpected argument: #{extra}"} - - {_opts, _argv, [{switch, _} | _]} -> - {:error, "bates start: unknown option: #{switch}"} - end - end - - defp do_run(opts) do - if path = opts[:config] do - Application.put_env(:bates, :config_path, Path.expand(path)) - end - - with :ok <- check_prerequisites(), - :ok <- check_not_running(), - :ok <- start_application() do - block_forever() - end - end - - defp check_prerequisites do - case Bates.Prerequisites.verify() do - :ok -> - :ok - - {:error, reason} -> - IO.write(:stderr, """ - bates: prerequisite not met: #{reason} - Run `bates setup` to configure system prerequisites. - """) - - 2 - end - end - - defp check_not_running do - case Client.get("/status", timeout: 500) do - {:ok, status, _body} when status in 200..299 -> - IO.write(:stderr, "Bates is already running.\n") - 1 - - _ -> - :ok - end - end - - defp start_application do - case Application.ensure_all_started(:bates) do - {:ok, _started} -> - :ok - - {:error, {app, reason}} -> - IO.write( - :stderr, - "bates: failed to start #{app}: #{inspect(reason)}\n" - ) - - 1 - end - end - - defp block_forever do - Process.sleep(:infinity) - end -end diff --git a/source/test/bates/cli/start_test.exs b/source/test/bates/cli/start_test.exs deleted file mode 100644 index 5cb097f..0000000 --- a/source/test/bates/cli/start_test.exs +++ /dev/null @@ -1,57 +0,0 @@ -defmodule Bates.CLI.StartTest do - use ExUnit.Case, async: false - - import ExUnit.CaptureIO - - alias Bates.CLI.Start - - # The successful boot path (`Application.ensure_all_started(:bates)`) - # is not unit-tested here because the `:bates` application is already - # started by ExUnit. It's verified by the manual smoke test. - - describe "argument parsing" do - test "rejects unknown switches with usage and exit code 2" do - stderr = - capture_io(:stderr, fn -> - assert Start.run(["--bogus"]) == 2 - end) - - assert stderr =~ "unknown option" - assert stderr =~ "Usage: bates start" - end - - test "rejects positional arguments with usage and exit code 2" do - stderr = - capture_io(:stderr, fn -> - assert Start.run(["extra"]) == 2 - end) - - assert stderr =~ "unexpected argument" - assert stderr =~ "Usage: bates start" - end - end - - describe "prerequisite gating" do - test "exits 2 with a setup pointer when caddy is missing" do - original_path = System.get_env("PATH") - System.put_env("PATH", "") - - try do - stderr = - capture_io(:stderr, fn -> - assert Start.run([]) == 2 - end) - - assert stderr =~ "prerequisite not met" - assert stderr =~ "`caddy` not found" - assert stderr =~ "Run `bates setup`" - after - if original_path do - System.put_env("PATH", original_path) - else - System.delete_env("PATH") - end - end - end - end -end diff --git a/source/test/bates/cli_test.exs b/source/test/bates/cli_test.exs index 73d3787..3e06014 100644 --- a/source/test/bates/cli_test.exs +++ b/source/test/bates/cli_test.exs @@ -9,7 +9,6 @@ defmodule Bates.CLITest do assert exit_code == 2 assert output =~ "Usage:" - assert output =~ "bates start" assert output =~ "bates setup" assert output =~ "bates status" assert output =~ "bates up " From 536fa4660c7b3f468a0cbb11bf61029d06a73fac Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Sun, 3 May 2026 00:57:34 -0400 Subject: [PATCH 2/8] Update not-running messaging to point at `batesd` The control commands previously told users to run `bates start` when the daemon was unreachable. With `bates start` removed and the daemon shipping as a Mix release named `batesd`, the diagnostic now reads "Bates is not running. Start it with: batesd." Also rewords `Bates.Prerequisites` and `Bates.CLI.Setup` docstrings to describe the gates and setup work in terms of `batesd`. --- source/lib/bates/cli/client.ex | 2 +- source/lib/bates/cli/setup.ex | 2 +- source/lib/bates/prerequisites.ex | 6 +++--- source/test/bates/cli/client_test.exs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/source/lib/bates/cli/client.ex b/source/lib/bates/cli/client.ex index 0dacc99..32ca12f 100644 --- a/source/lib/bates/cli/client.ex +++ b/source/lib/bates/cli/client.ex @@ -62,7 +62,7 @@ defmodule Bates.CLI.Client do @doc false def not_running_message, - do: "Bates is not running. Start it with: bates start" + do: "Bates is not running. Start it with: batesd" @doc false def ensure_apps do diff --git a/source/lib/bates/cli/setup.ex b/source/lib/bates/cli/setup.ex index 7f752dd..9fafd57 100644 --- a/source/lib/bates/cli/setup.ex +++ b/source/lib/bates/cli/setup.ex @@ -1,6 +1,6 @@ defmodule Bates.CLI.Setup do @moduledoc """ - One-time system setup for `bates start`. + One-time system setup for `batesd`. Two steps, both idempotent: diff --git a/source/lib/bates/prerequisites.ex b/source/lib/bates/prerequisites.ex index 9f17b26..9bc01be 100644 --- a/source/lib/bates/prerequisites.ex +++ b/source/lib/bates/prerequisites.ex @@ -1,16 +1,16 @@ defmodule Bates.Prerequisites do @moduledoc """ - System prerequisite checks gating `bates start`. + System prerequisite checks gating `batesd` startup. Today this covers the Caddy executable being on `$PATH` and the presence of `/etc/resolver/test`. Caddy's CA-trust step is no longer - a `bates start` prereq — it lives in `bates setup`. + a `batesd` prereq — it lives in `bates setup`. """ @resolver_path "/etc/resolver/test" @doc """ - Verifies all `bates start` prerequisites. + Verifies all `batesd` prerequisites. Returns `:ok` if every check passes, otherwise `{:error, reason}` with a user-facing message describing the first failure. diff --git a/source/test/bates/cli/client_test.exs b/source/test/bates/cli/client_test.exs index 9d130bd..1885de3 100644 --- a/source/test/bates/cli/client_test.exs +++ b/source/test/bates/cli/client_test.exs @@ -83,14 +83,14 @@ defmodule Bates.CLI.ClientTest do describe "transport_message/1" do test "maps :nxdomain to the canonical not-running message" do assert Client.transport_message(:nxdomain) == - "Bates is not running. Start it with: bates start" + "Bates is not running. Start it with: batesd" end test "maps {:failed_connect, _} to the canonical not-running message" do reason = {:failed_connect, [{:to_address, {~c"bates.test", 443}}]} assert Client.transport_message(reason) == - "Bates is not running. Start it with: bates start" + "Bates is not running. Start it with: batesd" end test "falls through for unrecognized errors" do From bfc7230914187e84d019a337e7911317ad6feefe Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Sun, 3 May 2026 01:00:52 -0400 Subject: [PATCH 3/8] Boot daemon prep from `Bates.Application.start/2` Move the argv parser, config-path application, and prereq verifier that the doomed `Bates.CLI.Start` owned into `Bates.Application.start/2` so the same supervision tree boots correctly under both `mix phx.server` and `bin/batesd` (the upcoming Mix release). The work lives in a new `Bates.Daemon` module as three pure helpers (`parse_argv/1`, `apply_options/1`, `verify_prerequisites/0`) so each piece is unit-testable without trapping `System.halt/1`. `start/2` itself is a thin orchestrator that halts with code 2 on parse or prereq failures, matching what `bates start` used to do. Tests run `Application.start/2` because `:bates` is a started application. To keep them from tripping the prereq gate (or being rejected by the argv parser, which sees `["test"]`), `config/test.exs` sets `config :bates, skip_prereq_check: true`, and `start/2` short- circuits the entire boot prep when that flag is set. The default of `false` keeps the gate active in dev and in the release. `Bates.Daemon` does NOT call `Mix.env/0` or any other build-time module: `Mix` is unavailable in releases. New `Bates.DaemonTest` covers `--config` parsing (default + override), unknown-switch and unexpected-positional rejection, application of the parsed `--config` to `:bates, :config_path`, and the prereq diagnostic when `caddy` is off `$PATH`. --- source/config/test.exs | 3 +- source/lib/bates/application.ex | 24 ++++++++++ source/lib/bates/daemon.ex | 67 +++++++++++++++++++++++++++ source/test/bates/daemon_test.exs | 77 +++++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 source/lib/bates/daemon.ex create mode 100644 source/test/bates/daemon_test.exs diff --git a/source/config/test.exs b/source/config/test.exs index 050fd8e..9a78a64 100644 --- a/source/config/test.exs +++ b/source/config/test.exs @@ -8,6 +8,7 @@ config :bates, BatesWeb.Endpoint, config :bates, poll_interval: 50, - readiness_timeout: 2_000 + readiness_timeout: 2_000, + skip_prereq_check: true config :logger, level: :warning diff --git a/source/lib/bates/application.ex b/source/lib/bates/application.ex index 4e5946a..0423f27 100644 --- a/source/lib/bates/application.ex +++ b/source/lib/bates/application.ex @@ -1,11 +1,35 @@ defmodule Bates.Application do use Application + alias Bates.Daemon + def start(_type, _args) do + boot_daemon() + opts = [strategy: :one_for_one, name: Bates.Supervisor] Supervisor.start_link(children(), opts) end + # When `:bates, :skip_prereq_check` is `true` (the default in + # `config/test.exs`) skip both argv parsing and the prereq check. + # `mix test` invokes `start/2` with `System.argv() == ["test"]`, which + # the daemon parser would reject as a stray positional argument. + defp boot_daemon do + if Application.get_env(:bates, :skip_prereq_check, false) do + :ok + else + with {:ok, opts} <- Daemon.parse_argv(System.argv()), + :ok <- Daemon.apply_options(opts), + :ok <- Daemon.verify_prerequisites() do + :ok + else + {:error, message} -> + IO.write(:stderr, message) + System.halt(2) + end + end + end + defp children do [ {Registry, keys: :unique, name: Bates.ProcessRegistry}, diff --git a/source/lib/bates/daemon.ex b/source/lib/bates/daemon.ex new file mode 100644 index 0000000..bb10693 --- /dev/null +++ b/source/lib/bates/daemon.ex @@ -0,0 +1,67 @@ +defmodule Bates.Daemon do + @moduledoc """ + Boot helpers for the `batesd` Mix release. + + `Bates.Application.start/2` calls these helpers to parse `System.argv()` + and verify system prerequisites before bringing up the supervision tree. + Each helper is pure (returns a tagged tuple) so it can be unit-tested + without trapping `System.halt/1`. + """ + + @usage "Usage: batesd [--config ]\n" + + @doc """ + Parses `argv` and returns the parsed options. + + Recognizes `--config `. On any unknown switch or unexpected + positional argument, returns `{:error, message}` where `message` is a + diagnostic followed by the usage banner. + """ + def parse_argv(argv) when is_list(argv) do + case OptionParser.parse(argv, strict: [config: :string]) do + {opts, [], []} -> + {:ok, opts} + + {_opts, [extra | _], _} -> + {:error, "batesd: unexpected argument: #{extra}\n" <> @usage} + + {_opts, _argv, [{switch, _} | _]} -> + {:error, "batesd: unknown option: #{switch}\n" <> @usage} + end + end + + @doc """ + Applies parsed daemon options to the application environment. + + Today only `--config` is honored; the parsed value is expanded and + stored under `:bates, :config_path` so `Bates.Config.path/0` picks + it up. + """ + def apply_options(opts) when is_list(opts) do + if path = opts[:config] do + Application.put_env(:bates, :config_path, Path.expand(path)) + end + + :ok + end + + @doc """ + Runs `Bates.Prerequisites.verify/0` and formats any failure for stderr. + + Returns `:ok` if every prereq passes. Returns `{:error, message}` with + the same diagnostic the old `bates start` emitted when a check fails. + """ + def verify_prerequisites do + case Bates.Prerequisites.verify() do + :ok -> + :ok + + {:error, reason} -> + {:error, + """ + bates: prerequisite not met: #{reason} + Run `bates setup` to configure system prerequisites. + """} + end + end +end diff --git a/source/test/bates/daemon_test.exs b/source/test/bates/daemon_test.exs new file mode 100644 index 0000000..04555f0 --- /dev/null +++ b/source/test/bates/daemon_test.exs @@ -0,0 +1,77 @@ +defmodule Bates.DaemonTest do + use ExUnit.Case, async: false + + alias Bates.Daemon + + describe "parse_argv/1" do + test "with no args returns empty options" do + assert {:ok, []} = Daemon.parse_argv([]) + end + + test "parses --config " do + assert {:ok, opts} = Daemon.parse_argv(["--config", "/tmp/foo.toml"]) + assert opts[:config] == "/tmp/foo.toml" + end + + test "rejects unknown switches with a usage banner" do + assert {:error, message} = Daemon.parse_argv(["--bogus"]) + assert message =~ "unknown option" + assert message =~ "Usage: batesd [--config ]" + end + + test "rejects unexpected positional arguments with a usage banner" do + assert {:error, message} = Daemon.parse_argv(["extra"]) + assert message =~ "unexpected argument" + assert message =~ "Usage: batesd [--config ]" + end + end + + describe "apply_options/1" do + setup do + original = Application.get_env(:bates, :config_path) + + on_exit(fn -> + if original do + Application.put_env(:bates, :config_path, original) + else + Application.delete_env(:bates, :config_path) + end + end) + + :ok + end + + test "without :config leaves :bates, :config_path unset" do + Application.delete_env(:bates, :config_path) + assert :ok = Daemon.apply_options([]) + assert Application.get_env(:bates, :config_path) == nil + end + + test "with :config expands and stores the path under :bates, :config_path" do + assert :ok = Daemon.apply_options(config: "~/foo.toml") + + expected = Path.expand("~/foo.toml") + assert Application.get_env(:bates, :config_path) == expected + end + end + + describe "verify_prerequisites/0" do + test "returns the formatted prereq diagnostic when caddy is missing" do + original_path = System.get_env("PATH") + System.put_env("PATH", "") + + try do + assert {:error, message} = Daemon.verify_prerequisites() + assert message =~ "prerequisite not met" + assert message =~ "`caddy` not found" + assert message =~ "Run `bates setup`" + after + if original_path do + System.put_env("PATH", original_path) + else + System.delete_env("PATH") + end + end + end + end +end From 8f33878e7bc25ab306f77d30bcb000aef2f1c332 Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Sun, 3 May 2026 01:06:14 -0400 Subject: [PATCH 4/8] Add `batesd` Mix release configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declare a `batesd` Mix release in `source/mix.exs`'s `releases:` keyword. The release embeds ERTS, includes every dependency's `priv/` directory, and starts `:bates` permanently — so `erlexec` finds its `exec-port` C binary at runtime, which the escript packaging format could not deliver. `mix release` generates `bin/batesd` as a multi-subcommand dispatcher (`start`, `daemon`, `remote`, `eval`, ...). A custom `:steps` callback (`install_launcher/1`) renames that file to `bin/batesd-orig` and writes a thin wrapper at `bin/batesd` that always invokes the foreground `start` subcommand. Users see a single command: `batesd [--config ]` — no subcommand maze. The wrapper is written directly inside the `:steps` callback rather than via `rel/overlays/`. Overlays are copied during `:assemble`, which makes the rename-then-overlay sequence awkward; doing the rename and the wrapper write in the same custom step keeps the ordering obvious. Add a minimal `config/prod.exs` so `MIX_ENV=prod mix release batesd` can read environment-specific endpoint settings. The file mirrors `dev.exs` for now (port and `secret_key_base`); follow-up work can diverge as needed. `config/runtime.exs` is intentionally absent — the release reads its only environment-dependent setting (`--config `) from argv inside `Bates.Application.start/2`. --- source/config/prod.exs | 8 ++++++++ source/mix.exs | 45 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 source/config/prod.exs diff --git a/source/config/prod.exs b/source/config/prod.exs new file mode 100644 index 0000000..1861486 --- /dev/null +++ b/source/config/prod.exs @@ -0,0 +1,8 @@ +import Config + +config :bates, BatesWeb.Endpoint, + http: [port: 4080], + secret_key_base: + "8d92b2f472c7deb4054a5fa64b1f1d32572f9a1495cb6e7bdbd34837fa3352d1fe70201c37ded9d7a7e7f95d67780e2877e515839f46c51fbc5a87f9f000f67e" + +config :logger, level: :info diff --git a/source/mix.exs b/source/mix.exs index 8a865be..35a34a2 100644 --- a/source/mix.exs +++ b/source/mix.exs @@ -9,7 +9,8 @@ defmodule Bates.MixProject do elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), - escript: [main_module: Bates.CLI, name: "bates", app: nil] + escript: [main_module: Bates.CLI, name: "bates", app: nil], + releases: releases() ] end @@ -39,4 +40,46 @@ defmodule Bates.MixProject do {:lazy_html, ">= 0.1.0", only: :test} ] end + + defp releases do + [ + batesd: [ + version: "0.1.0", + applications: [bates: :permanent], + include_executables_for: [:unix], + # `mix release` generates `bin/batesd` as a multi-subcommand + # dispatcher (`start`, `daemon`, `remote`, `eval`, ...). The + # `&install_launcher/1` step renames it to `bin/batesd-orig` + # and writes a thin wrapper at `bin/batesd` that always invokes + # the foreground `start` subcommand. The user-facing surface is + # `batesd [--config ]` — no subcommand. + # + # The wrapper is written directly here (rather than via a + # `rel/overlays/` file) so we don't fight the overlay/launcher + # ordering during `:assemble`. + steps: [:assemble, &install_launcher/1] + ] + ] + end + + defp install_launcher(release) do + bin = Path.join(release.path, "bin") + generated = Path.join(bin, "batesd") + renamed = Path.join(bin, "batesd-orig") + File.rename!(generated, renamed) + + wrapper = """ + #!/bin/sh + # Thin wrapper installed by Bates' release `:steps` callback. + # Always invokes the foreground `start` subcommand of the generated + # mix-release launcher (`batesd-orig`). Users see a single command: + # `batesd [--config ]`. + exec "$(dirname "$0")/batesd-orig" start "$@" + """ + + File.write!(generated, wrapper) + File.chmod!(generated, 0o755) + + release + end end From 1e2c1ea5bbc2f5256796a238dea891c882ae202d Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Sun, 3 May 2026 01:07:41 -0400 Subject: [PATCH 5/8] Update specs for the two-binary topology `specs/cli.md` removes the `### bates start` section and the option table that came with it, points the not-running message at `batesd`, points the configuration override description at `batesd`, and rewords the "How It Connects" `bates start` bullet to describe `batesd`. A new `## Daemon` section documents `batesd`'s flags, prereq behavior, and intended invocation (foreground until the launchd v2 proposal lands). The intro paragraph now describes the two binaries explicitly. `specs/system-overview.md`'s `### CLI` section now spells out the two-binary topology: `batesd` is the server (Mix release, embeds ERTS so `erlexec` finds its priv binary), and `bates` is a thin client escript for the control commands. --- specs/cli.md | 70 ++++++++++++++++++++++++---------------- specs/system-overview.md | 16 ++++++--- 2 files changed, 54 insertions(+), 32 deletions(-) diff --git a/specs/cli.md b/specs/cli.md index 84dc2a3..5e8badf 100644 --- a/specs/cli.md +++ b/specs/cli.md @@ -1,32 +1,17 @@ # CLI -Bates provides a command-line interface for launching the server, -managing applications, and performing system setup. Running `bates` -with no arguments displays usage information. +Bates ships two binaries. `batesd` is the server (a Mix release that +boots the OTP supervision tree). `bates` is a thin client escript for +managing applications and performing system setup against a running +`batesd`. Running `bates` with no arguments displays usage +information. ## Commands -### `bates start` - -Starts the Bates server in the foreground. Launches the OTP -supervision tree, starts Caddy, and begins streaming the interleaved -log output to stdout. Ctrl-C shuts everything down. - -Before starting, checks that system prerequisites are in place: - -1. `caddy` is on `$PATH`. -2. `/etc/resolver/test` exists (DNS resolution for `*.test`). - -If either check fails, prints a message pointing to `bates setup` -and exits. Bates does not attempt to fix prerequisites automatically. -Caddy's local CA root certificate trust is set up during `bates -setup`, not gated here. - -#### Options - -| Flag | Description | -|------|-------------| -| `--config ` | Path to the configuration file. Defaults to `~/.config/bates/config.toml`. | +The Bates server itself is a separate binary, `batesd`. The `bates` +escript is a thin client for the control commands below; it does not +boot the OTP tree. See [System Overview](system-overview.md) for the +two-binary topology and [Daemon](#daemon) below for `batesd`'s flags. ### `bates setup` @@ -125,6 +110,33 @@ eval "$(bates env myapp 2>/dev/null || true)" exports that changed since the shell loaded the file (for example, after a stop/start cycle assigned a fresh `PGPORT`). +## Daemon + +The server is a separate binary named `batesd`, built as a Mix release +(`MIX_ENV=prod mix release batesd`). It boots the OTP supervision +tree, runs Caddy as a managed child process, and serves the JSON API +and dashboard. Run it directly in a terminal — Ctrl-C shuts it down. + +Before bringing the supervision tree up, `batesd` checks that system +prerequisites are in place: + +1. `caddy` is on `$PATH`. +2. `/etc/resolver/test` exists (DNS resolution for `*.test`). + +If either check fails, `batesd` writes a diagnostic pointing at +`bates setup` to stderr and exits with code 2. Bates does not attempt +to fix prerequisites automatically. Caddy's local CA root certificate +trust is set up during `bates setup`, not gated here. + +A future proposal will install a launchd job via `bates setup` so +`batesd` is system-supervised; until then users invoke it manually. + +### Options + +| Flag | Description | +|------|-------------| +| `--config ` | Path to the configuration file. Defaults to `~/.config/bates/config.toml`. | + ## Server Communication Control commands (`status`, `up`, `down`, `restart`, `env`) @@ -132,22 +144,24 @@ communicate with the running server via the JSON API on `bates.test`. If the server is not running, they exit with a clear error message: ``` -Bates is not running. Start it with: bates start +Bates is not running. Start it with: batesd ``` ## Configuration The default configuration file location is `~/.config/bates/config.toml`. This can be overridden with the -`--config` flag on `bates start`. +`--config` flag on `batesd`. See [Process Management](process-management.md) for the configuration format. ## How It Connects -- **`bates start`** launches the OTP application, which starts the - ProcessSupervisor, Caddy, and the control interface (Phoenix). +- **`batesd`** is the server. It launches the OTP application, which + starts the ProcessSupervisor, Caddy, and the control interface + (Phoenix). Run it directly in a terminal; future versions will + install a launchd job via `bates setup`. - **`bates setup`** is standalone — it does not require the server to be running. - **Control commands** are thin wrappers around the JSON API defined in diff --git a/specs/system-overview.md b/specs/system-overview.md index de2c426..bb85117 100644 --- a/specs/system-overview.md +++ b/specs/system-overview.md @@ -94,10 +94,18 @@ The on-demand startup flow (app not running): ### CLI -The command-line interface for launching and interacting with Bates. -`bates start` runs the server in the foreground. Control commands -(`status`, `up`, `down`, `restart`) communicate with the running server -via the JSON API. +Bates ships two binaries: + +- `batesd` is the server. It boots the OTP supervision tree, runs + Caddy as a managed child process, and serves the JSON API and + dashboard. Users invoke it directly in the foreground. It is built + as a Mix release so `erlexec`'s native `priv/exec-port` binary is + available at runtime. +- `bates` is a thin client for control commands. `bates env`, + `bates status`, `bates up`, `bates down`, `bates restart`, and + `bates setup` all speak to the running `batesd` over the JSON API + (except `bates setup`, which is standalone). It is built as an + escript so cold-start stays fast. See [CLI](cli.md). From 34e5dde4ad3a79ef2334d8eb401939ea4646590d Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Sun, 3 May 2026 01:08:04 -0400 Subject: [PATCH 6/8] Document `mix release batesd` in the README Add a "Build and Run" section between "Setup" and "Configuration" covering both binaries: `mix escript.build` for the `bates` client and `MIX_ENV=prod mix release batesd` for the daemon. Includes the `--config` flag, the foreground/Ctrl-C semantics, and a couple of sample `bates` invocations against a running daemon. --- README.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/README.md b/README.md index 693d28d..1aa16f9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,34 @@ its root CA so browsers accept the certificates: caddy trust ``` +## Build and Run + +Bates ships two binaries: `batesd` (the server, a Mix release) and +`bates` (a thin escript client for control commands). From `source/`: + +```bash +mix deps.get +mix escript.build # produces source/bates (the CLI) +MIX_ENV=prod mix release batesd # produces source/_build/prod/rel/batesd/ +``` + +Run the daemon in the foreground: + +```bash +_build/prod/rel/batesd/bin/batesd +``` + +Pass `--config ` to override the default config location +(`~/.config/bates/config.toml`). Ctrl-C shuts the daemon down. + +The `bates` escript talks to the running daemon via the JSON API. +With `bates` and `batesd` both on `$PATH`: + +```bash +bates status +bates env myapp +``` + ## Configuration The configuration file is defined in [TOML][] format. From 378242e4ab4e1791fb621a9d071633e1f6b149e7 Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Sun, 3 May 2026 01:18:44 -0400 Subject: [PATCH 7/8] Pass `--config` to the release via `BATES_CONFIG_PATH` The mix-release `start` subcommand discards extra argv, and the generated `elixir` launcher's CLI mode interprets argv as `[script | args]` (so passing `--config` directly would make it treat the flag as a script filename). Have the `bin/batesd` wrapper parse `--config` in shell and export it as the `BATES_CONFIG_PATH` environment variable, then add `Bates.Daemon.apply_env/1` to plumb that into `:bates, :config_path`. `Bates.Application.start/2` now runs the env step after argv so the env var wins when both are set (the wrapper sets it deliberately; the argv path stays for `mix phx.server`). --- source/lib/bates/application.ex | 1 + source/lib/bates/daemon.ex | 44 +++++++++++++++++++++++++--- source/mix.exs | 48 ++++++++++++++++++++++++++++--- source/test/bates/daemon_test.exs | 42 +++++++++++++++++++++++++++ 4 files changed, 127 insertions(+), 8 deletions(-) diff --git a/source/lib/bates/application.ex b/source/lib/bates/application.ex index 0423f27..9379a45 100644 --- a/source/lib/bates/application.ex +++ b/source/lib/bates/application.ex @@ -20,6 +20,7 @@ defmodule Bates.Application do else with {:ok, opts} <- Daemon.parse_argv(System.argv()), :ok <- Daemon.apply_options(opts), + :ok <- Daemon.apply_env(), :ok <- Daemon.verify_prerequisites() do :ok else diff --git a/source/lib/bates/daemon.ex b/source/lib/bates/daemon.ex index bb10693..9cce32b 100644 --- a/source/lib/bates/daemon.ex +++ b/source/lib/bates/daemon.ex @@ -2,12 +2,26 @@ defmodule Bates.Daemon do @moduledoc """ Boot helpers for the `batesd` Mix release. - `Bates.Application.start/2` calls these helpers to parse `System.argv()` - and verify system prerequisites before bringing up the supervision tree. - Each helper is pure (returns a tagged tuple) so it can be unit-tested - without trapping `System.halt/1`. + `Bates.Application.start/2` calls these helpers to parse the daemon's + inputs and verify system prerequisites before bringing up the + supervision tree. Each helper is pure (returns a tagged tuple) so it + can be unit-tested without trapping `System.halt/1`. + + Two input paths feed `:bates, :config_path`: + + * `mix phx.server` (and any other dev/release entry point that + exposes argv) goes through `parse_argv/1` + `apply_options/1`. + * The `bin/batesd` Mix-release wrapper parses argv in shell and + sets the `BATES_CONFIG_PATH` environment variable, which + `apply_env/1` picks up. (The mix-release `start` subcommand + discards extra argv, so the wrapper translates the only flag we + accept into env.) + + When both are set the env var wins (the wrapper sets it + deliberately). """ + @env_config_path "BATES_CONFIG_PATH" @usage "Usage: batesd [--config ]\n" @doc """ @@ -45,6 +59,28 @@ defmodule Bates.Daemon do :ok end + @doc """ + Applies daemon options sourced from the environment. + + Reads `BATES_CONFIG_PATH` (set by the `bin/batesd` wrapper) and + stores it under `:bates, :config_path`, overriding anything + `apply_options/1` previously set. The argv path stays useful for + `mix phx.server`; the env path covers the Mix release. + """ + def apply_env(env \\ System.get_env()) when is_map(env) do + case Map.get(env, @env_config_path) do + nil -> + :ok + + "" -> + :ok + + path -> + Application.put_env(:bates, :config_path, Path.expand(path)) + :ok + end + end + @doc """ Runs `Bates.Prerequisites.verify/0` and formats any failure for stderr. diff --git a/source/mix.exs b/source/mix.exs index 35a34a2..5659a3d 100644 --- a/source/mix.exs +++ b/source/mix.exs @@ -68,13 +68,53 @@ defmodule Bates.MixProject do renamed = Path.join(bin, "batesd-orig") File.rename!(generated, renamed) + # The user-facing surface is `batesd [--config ]`. The + # mix-release `start` subcommand discards extra argv, and the + # generated `elixir` launcher's CLI mode interprets argv as + # `[script | args]` (so passing `--config` directly would make + # Elixir try to load `--config` as a script). Instead, the wrapper + # parses our flags in shell and exports them as environment + # variables that `Bates.Application.start/2` reads. wrapper = """ #!/bin/sh # Thin wrapper installed by Bates' release `:steps` callback. - # Always invokes the foreground `start` subcommand of the generated - # mix-release launcher (`batesd-orig`). Users see a single command: - # `batesd [--config ]`. - exec "$(dirname "$0")/batesd-orig" start "$@" + # Translates `batesd [--config ]` into environment variables + # that the daemon reads, then execs the underlying mix-release + # launcher's foreground `start` subcommand. + + set -e + DIR=$(dirname "$0") + + while [ $# -gt 0 ]; do + case "$1" in + --config) + if [ -z "$2" ]; then + echo "batesd: --config requires a path" >&2 + echo "Usage: batesd [--config ]" >&2 + exit 2 + fi + BATES_CONFIG_PATH="$2" + export BATES_CONFIG_PATH + shift 2 + ;; + --config=*) + BATES_CONFIG_PATH="${1#--config=}" + export BATES_CONFIG_PATH + shift + ;; + --help|-h) + echo "Usage: batesd [--config ]" + exit 0 + ;; + *) + echo "batesd: unknown argument: $1" >&2 + echo "Usage: batesd [--config ]" >&2 + exit 2 + ;; + esac + done + + exec "$DIR/batesd-orig" start """ File.write!(generated, wrapper) diff --git a/source/test/bates/daemon_test.exs b/source/test/bates/daemon_test.exs index 04555f0..21d6111 100644 --- a/source/test/bates/daemon_test.exs +++ b/source/test/bates/daemon_test.exs @@ -55,6 +55,48 @@ defmodule Bates.DaemonTest do end end + describe "apply_env/1" do + setup do + original = Application.get_env(:bates, :config_path) + + on_exit(fn -> + if original do + Application.put_env(:bates, :config_path, original) + else + Application.delete_env(:bates, :config_path) + end + end) + + :ok + end + + test "without BATES_CONFIG_PATH leaves :bates, :config_path unchanged" do + Application.put_env(:bates, :config_path, "/from/argv.toml") + assert :ok = Daemon.apply_env(%{}) + assert Application.get_env(:bates, :config_path) == "/from/argv.toml" + end + + test "with empty BATES_CONFIG_PATH leaves :bates, :config_path unchanged" do + Application.put_env(:bates, :config_path, "/from/argv.toml") + assert :ok = Daemon.apply_env(%{"BATES_CONFIG_PATH" => ""}) + assert Application.get_env(:bates, :config_path) == "/from/argv.toml" + end + + test "with BATES_CONFIG_PATH expands and stores under :bates, :config_path" do + assert :ok = + Daemon.apply_env(%{"BATES_CONFIG_PATH" => "~/from-env.toml"}) + + assert Application.get_env(:bates, :config_path) == + Path.expand("~/from-env.toml") + end + + test "BATES_CONFIG_PATH overrides a previously argv-set config path" do + Application.put_env(:bates, :config_path, "/from/argv.toml") + assert :ok = Daemon.apply_env(%{"BATES_CONFIG_PATH" => "/from/env.toml"}) + assert Application.get_env(:bates, :config_path) == "/from/env.toml" + end + end + describe "verify_prerequisites/0" do test "returns the formatted prereq diagnostic when caddy is missing" do original_path = System.get_env("PATH") From 83f12d7f50c3cb0e0a0d151d8ca6ce0398c04982 Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Sun, 3 May 2026 01:20:04 -0400 Subject: [PATCH 8/8] Archive mix-release-for-daemon plan with execution notes --- .../mix-release-for-daemon.md | 71 ++++++++++++++----- 1 file changed, 52 insertions(+), 19 deletions(-) rename workflow/plans/{active => archived}/mix-release-for-daemon.md (83%) diff --git a/workflow/plans/active/mix-release-for-daemon.md b/workflow/plans/archived/mix-release-for-daemon.md similarity index 83% rename from workflow/plans/active/mix-release-for-daemon.md rename to workflow/plans/archived/mix-release-for-daemon.md index 052d54e..590d368 100644 --- a/workflow/plans/active/mix-release-for-daemon.md +++ b/workflow/plans/archived/mix-release-for-daemon.md @@ -21,25 +21,25 @@ Users will invoke `batesd` directly to start the server. A future proposal will ## Acceptance Criteria -- [ ] `Bates.CLI.Start` module deleted. -- [ ] `start` clause removed from `Bates.CLI.dispatch/1`; `bates start` no longer appears in `Bates.CLI.usage/0` output. -- [ ] `source/test/bates/cli/start_test.exs` deleted. -- [ ] `Bates.CLITest`'s usage assertions updated — no assertion on `"bates start"` remaining. -- [ ] `Bates.CLI.Client.not_running_message/0` points users at `batesd`, not `bates start`. Tests updated to match. -- [ ] `Bates.Prerequisites` and `Bates.CLI.Setup` docstrings no longer reference `bates start`. -- [ ] `source/mix.exs` declares a `releases:` keyword with a `batesd` release. -- [ ] `MIX_ENV=prod mix release batesd` succeeds and produces `source/_build/prod/rel/batesd/bin/batesd`. -- [ ] `Bates.Application.start/2` parses `System.argv()` for `--config `, runs `Bates.Prerequisites.verify/0`, and emits the same diagnostic + non-zero exit on failure that `bates start` did. -- [ ] `source/config/test.exs` sets `config :bates, skip_prereq_check: true` so the prereq gate doesn't fire during `mix test`. -- [ ] No `Mix.*` call appears in `Bates.Application.start/2` or any module reachable from it. (`Mix` is build-time only and unavailable in releases.) -- [ ] An overlay at `source/rel/overlays/bin/batesd` makes `bin/batesd` (no subcommand) the foreground command. `bin/batesd --config /path/to/foo.toml` boots the supervision tree. -- [ ] `source/test/bates/daemon_test.exs` covers `--config` parsing (default + override) and the prereq exit path. -- [ ] `mix test` passes. -- [ ] `mix format --check-formatted` passes. -- [ ] `specs/cli.md`'s `### bates start` section removed; remaining `bates start` references updated to point at `batesd`. -- [ ] `specs/system-overview.md` mentions the two-binary topology and that `batesd` is the server entry point. -- [ ] `README.md` documents `mix release batesd` and how to run `batesd`. -- [ ] Manual smoke test: `_build/prod/rel/batesd/bin/batesd` boots, serves the dashboard at `https://bates.test`, Ctrl-C shuts it down. `bates status`, `bates up `, and `bates env ` all work against the running daemon. +- [x] `Bates.CLI.Start` module deleted. +- [x] `start` clause removed from `Bates.CLI.dispatch/1`; `bates start` no longer appears in `Bates.CLI.usage/0` output. +- [x] `source/test/bates/cli/start_test.exs` deleted. +- [x] `Bates.CLITest`'s usage assertions updated — no assertion on `"bates start"` remaining. +- [x] `Bates.CLI.Client.not_running_message/0` points users at `batesd`, not `bates start`. Tests updated to match. +- [x] `Bates.Prerequisites` and `Bates.CLI.Setup` docstrings no longer reference `bates start`. +- [x] `source/mix.exs` declares a `releases:` keyword with a `batesd` release. +- [x] `MIX_ENV=prod mix release batesd` succeeds and produces `source/_build/prod/rel/batesd/bin/batesd`. +- [x] `Bates.Application.start/2` parses `System.argv()` for `--config `, runs `Bates.Prerequisites.verify/0`, and emits the same diagnostic + non-zero exit on failure that `bates start` did. +- [x] `source/config/test.exs` sets `config :bates, skip_prereq_check: true` so the prereq gate doesn't fire during `mix test`. +- [x] No `Mix.*` call appears in `Bates.Application.start/2` or any module reachable from it. (`Mix` is build-time only and unavailable in releases.) +- [x] An overlay at `source/rel/overlays/bin/batesd` makes `bin/batesd` (no subcommand) the foreground command. `bin/batesd --config /path/to/foo.toml` boots the supervision tree. +- [x] `source/test/bates/daemon_test.exs` covers `--config` parsing (default + override) and the prereq exit path. +- [x] `mix test` passes. +- [x] `mix format --check-formatted` passes. +- [x] `specs/cli.md`'s `### bates start` section removed; remaining `bates start` references updated to point at `batesd`. +- [x] `specs/system-overview.md` mentions the two-binary topology and that `batesd` is the server entry point. +- [x] `README.md` documents `mix release batesd` and how to run `batesd`. +- [ ] Manual smoke test: `_build/prod/rel/batesd/bin/batesd` boots, serves the dashboard at `https://bates.test`, Ctrl-C shuts it down. `bates status`, `bates up `, and `bates env ` all work against the running daemon. (User to verify before merge.) ## Phases @@ -372,3 +372,36 @@ None. The plan is execution-ready as written. ### Blockers None identified. + +--- + +## Execution Notes + +### Assumptions Confirmed + +- `MIX_ENV=prod mix release batesd` builds cleanly on this codebase (POC gap #1). +- `mix test` passes with `skip_prereq_check: true` in `config/test.exs` even on a machine without `caddy` on `$PATH` (POC gap #4). + +### Deviations From Plan + +- **Phase 3 — `skip_prereq_check` gate widened.** The plan said the flag skips only the prereq check. In practice, `mix test` invokes `Bates.Application.start/2` with `System.argv() == ["test"]`, which the new daemon argv parser rejects as a stray positional. Gated the entire `boot_daemon` orchestrator (argv parse + apply + prereq) on the flag, not just the prereq step. Same intent (don't trip on dev machines), broader scope. +- **Phase 3 — extracted `Bates.Daemon` module.** The plan said inline if `start/2` orchestration stays under three lines. The boot pipeline grew to four steps (`parse_argv` → `apply_options` → `apply_env` → `verify_prerequisites`); extracted into `Bates.Daemon` for testability. `start/2` stays a thin wrapper around `boot_daemon/0`. +- **Phase 4 — overlay mechanism abandoned for direct write.** The plan preferred option 1 (custom `:steps` callback to rename the generated launcher, then drop an overlay at `bin/batesd`). Sandbox restrictions made the overlay's `chmod +x` impossible to commit. Switched to the documented fallback: do the rename + write + `File.chmod!` entirely inside the `install_launcher/1` callback. No `source/rel/overlays/` directory exists in the final tree. +- **Phase 4 — added `config/prod.exs`.** Not in the plan. `config/config.exs` does `import_config "#{config_env()}.exs"` and the prod release fails to assemble without a `config/prod.exs`. Added a minimal stub mirroring `dev.exs` (Endpoint port 4080, secret_key_base, `:info` log level). +- **Phase 4 — env-var path for `--config`.** Big surprise mid-Phase 4: `_build/prod/rel/batesd/bin/batesd --config /x` did not honor the flag. Two stacked problems: (1) the mix-release `start` subcommand discards extra argv at the shell level, and (2) the `elixir` launcher's CLI mode interprets argv as `[script | args]`, so even after patching `start)` to forward `"$@"`, `--config` got read as a script filename and `start_cli` halted. Solved by having `bin/batesd` parse `--config` in shell, export it as `BATES_CONFIG_PATH`, then `exec batesd-orig start` with no argv. Added `Bates.Daemon.apply_env/1` to plumb the env var into `:bates, :config_path`. The argv path stays for `mix phx.server`; the env path covers the release. When both are set the env wins (the wrapper sets it deliberately). + +### Gotchas Worth Documenting + +- **`Mix.env/0` is unavailable in releases.** Plan called this out; confirmed during execution. The `skip_prereq_check` Application env var is the right approach. +- **Mix-release `start)` case discards argv.** Anyone trying to pass argv through the launcher will hit this. The env-var indirection is the cleanest workaround that doesn't require patching the upstream launcher template. +- **The escript no longer boots the OTP application.** Earlier work (commit `aaeb43f`) flipped `app: nil` in `mix.exs`'s `escript:` config so `bates env` doesn't accidentally start Caddy. This is unrelated to this plan but worth noting for context: the daemon path is `batesd` only; the escript is purely a JSON API client (plus `bates setup`, which shells out). + +## Execution Stats + +| Metric | Value | +|--------|-------| +| Duration | 00:51 → 01:18 (~27 minutes) | +| Files changed | 17 (vs `master`) | +| Commits | 7 | +| Tests added | 1 file (`source/test/bates/daemon_test.exs`, 11 tests) | +| Token totals (input / cache_create / cache_read / output) | _to be filled by launcher_ |