···4242 end
43434444 defp build_steps(%{eval_only: true}), do: [:eval]
4545- defp build_steps(%{push: true}), do: [:eval, :build, :push]
4645 defp build_steps(%{seed: true}), do: [:eval, :build, :push, :seed]
4646+ defp build_steps(%{push: true}), do: [:eval, :build, :push]
4747 defp build_steps(_), do: [:eval, :build]
48484949 defp validate_options(steps, options) do
5050 cond do
5151 :push in steps and is_nil(options.cache) ->
5252- Output.error("--cache is required for --push")
5353- {:error, :missing_cache}
5252+ config = SowerCli.Config.get()
54535555- :seed in steps and is_nil(options.endpoint) ->
5656- Output.error("--endpoint is required for --seed")
5757- {:error, :missing_endpoint}
5454+ if is_nil(config.cache) do
5555+ Output.error("--cache is required for --push")
5656+ {:error, :missing_cache}
5757+ else
5858+ :ok
5959+ end
6060+6161+ :seed in steps ->
6262+ config = SowerCli.Config.get()
6363+6464+ try do
6565+ SowerCli.Config.require_server_connection!(config)
6666+ :ok
6767+ rescue
6868+ e in ArgumentError ->
6969+ Output.error("#{e.message}")
7070+ {:error, :missing_server_config}
7171+ end
58725973 true ->
6074 :ok
···129143 defp run_steps([:push | rest], %__MODULE__{} = state) do
130144 Output.step("Pushing to cache")
131145132132- {:ok, {cache_module, cache_config}} = Cache.parse_url(state.options.cache)
146146+ cache_url = state.options.cache || SowerCli.Config.get().cache
147147+ {:ok, {cache_module, cache_config}} = Cache.parse_url(cache_url)
133148134149 store_paths =
135150 state.builds
+55
apps/sower_cli/lib/sower_cli/config.ex
···11+defmodule SowerCli.Config do
22+ @moduledoc """
33+ CLI configuration management.
44+55+ Delegates to `SowerClient.Config` for loading and validation.
66+ All fields are optional - CLI arguments can override config file values.
77+ """
88+99+ @app :sower_cli
1010+1111+ def load(overrides \\ %{}) do
1212+ config =
1313+ SowerClient.Config.load(overrides,
1414+ config_path_env: "SOWER_CLI_CONFIG"
1515+ )
1616+1717+ Application.put_env(@app, :config, config)
1818+ config
1919+ end
2020+2121+ def get do
2222+ Application.get_env(@app, :config)
2323+ end
2424+2525+ @doc """
2626+ Validate that endpoint and access_token are present for server operations.
2727+2828+ Raises an error if either field is missing.
2929+ """
3030+ def require_server_connection!(%SowerClient.Config{} = config) do
3131+ errors = []
3232+3333+ errors =
3434+ if is_nil(config.endpoint) do
3535+ ["endpoint is required (set via config file or --endpoint flag)" | errors]
3636+ else
3737+ errors
3838+ end
3939+4040+ errors =
4141+ if is_nil(config.access_token) do
4242+ ["access_token is required (set via config file or access_token_file)" | errors]
4343+ else
4444+ errors
4545+ end
4646+4747+ case errors do
4848+ [] ->
4949+ :ok
5050+5151+ _ ->
5252+ raise ArgumentError, Enum.join(errors, ", ")
5353+ end
5454+ end
5555+end
···11+defmodule SowerCli.ConfigTest do
22+ use ExUnit.Case, async: true
33+44+ describe "load/1" do
55+ test "loads config and stores in application env" do
66+ config =
77+ SowerCli.Config.load(%{"endpoint" => "https://test.com", "access_token_file" => nil})
88+99+ assert %SowerClient.Config{} = config
1010+ assert config.endpoint == "https://test.com"
1111+1212+ # Verify it's stored in app env
1313+ assert SowerCli.Config.get() == config
1414+ end
1515+ end
1616+1717+ describe "get/0" do
1818+ test "returns cached config from application env" do
1919+ config =
2020+ SowerCli.Config.load(%{"cache" => "attic://server:cache", "access_token_file" => nil})
2121+2222+ cached = SowerCli.Config.get()
2323+2424+ assert cached == config
2525+ assert cached.cache == "attic://server:cache"
2626+ end
2727+ end
2828+2929+ describe "require_server_connection!/1" do
3030+ test "raises when endpoint is missing" do
3131+ config = %SowerClient.Config{access_token: "token123"}
3232+3333+ assert_raise ArgumentError, ~r/endpoint is required/, fn ->
3434+ SowerCli.Config.require_server_connection!(config)
3535+ end
3636+ end
3737+3838+ test "raises when access_token is missing" do
3939+ config = %SowerClient.Config{endpoint: "https://test.com"}
4040+4141+ assert_raise ArgumentError, ~r/access_token is required/, fn ->
4242+ SowerCli.Config.require_server_connection!(config)
4343+ end
4444+ end
4545+4646+ test "returns :ok when both are present" do
4747+ config = %SowerClient.Config{
4848+ endpoint: "https://test.com",
4949+ access_token: "token123"
5050+ }
5151+5252+ assert :ok = SowerCli.Config.require_server_connection!(config)
5353+ end
5454+ end
5555+end
+235
apps/sower_client/lib/sower_client/config.ex
···11+defmodule SowerClient.Config do
22+ @moduledoc """
33+ Shared configuration for Sower tools (agent, CLI).
44+55+ Supports `_file` suffix for reading secrets from files.
66+77+ Default config location: `~/.config/sower/client.json` or `/etc/sower/client.json`
88+ """
99+1010+ use TypedStruct
1111+ alias OpenApiSpex.Schema
1212+ require Logger
1313+ require OpenApiSpex
1414+1515+ OpenApiSpex.schema(%{
1616+ title: "Config",
1717+ type: :object,
1818+ properties: %{
1919+ access_token: %Schema{
2020+ type: :string,
2121+ description: "Sower access token",
2222+ readOnly: true
2323+ },
2424+ cache: %Schema{
2525+ type: :string,
2626+ description: "Default cache URL for pushing builds",
2727+ example: "attic://server:cache"
2828+ },
2929+ config_path: %Schema{
3030+ type: :string,
3131+ description: "config file path",
3232+ readOnly: true
3333+ },
3434+ endpoint: %Schema{
3535+ type: :string,
3636+ format: :uri,
3737+ description: "Sower server endpoint",
3838+ example: "https://my.sower.dev"
3939+ },
4040+ name: %Schema{
4141+ type: :string,
4242+ description: "Agent name (agent-only)",
4343+ default: "system hostname"
4444+ },
4545+ state_directory: %Schema{
4646+ type: :string,
4747+ description: "Directory where state files are written (agent-only)",
4848+ default: "/var/lib/sower_agent"
4949+ },
5050+ subscriptions: %Schema{
5151+ type: :array,
5252+ items: SowerClient.Schemas.Orchestration.Subscription,
5353+ default: [],
5454+ description: "Agent subscriptions (agent-only)"
5555+ }
5656+ },
5757+ required: []
5858+ })
5959+6060+ @doc """
6161+ Load configuration from file and overrides.
6262+ """
6363+ def load(overrides \\ %{}, opts \\ []) do
6464+ Application.ensure_all_started(:logger)
6565+6666+ spec = build_spec()
6767+6868+ config_path = resolve_config_path(opts)
6969+7070+ config =
7171+ Keyword.get(opts, :defaults, %{})
7272+ |> Map.merge(read_config_file(config_path))
7373+ |> then(fn cfg ->
7474+ if File.exists?(config_path) do
7575+ Map.put(cfg, :config_path, config_path)
7676+ else
7777+ cfg
7878+ end
7979+ end)
8080+ |> normalize_subscription_rules()
8181+ |> Map.merge(overrides)
8282+ |> parse_file_values()
8383+ |> OpenApiSpex.cast_value(spec.components.schemas["Config"], spec)
8484+ |> case do
8585+ {:ok, cfg} ->
8686+ cfg
8787+8888+ {:error, errors} ->
8989+ Logger.error(msg: "Failed to read configuration", errors: errors)
9090+ Kernel.exit(1)
9191+ end
9292+9393+ config
9494+ end
9595+9696+ def build_spec do
9797+ %OpenApiSpex.OpenApi{
9898+ info: %OpenApiSpex.Info{title: "Config", version: "1.0.0"},
9999+ paths: %{},
100100+ components: nil
101101+ }
102102+ |> OpenApiSpex.resolve_schema_modules()
103103+ |> OpenApiSpex.add_schemas([__MODULE__])
104104+ end
105105+106106+ def read_config_file(path) do
107107+ if File.exists?(path) do
108108+ path
109109+ |> File.read!()
110110+ |> Jason.decode!()
111111+ else
112112+ Logger.warning(msg: "Config file is missing, using defaults", file: path)
113113+ %{}
114114+ end
115115+ end
116116+117117+ @doc """
118118+ Resolve config file path from options and environment.
119119+120120+ Config path resolution order:
121121+ 1. Explicit `:config_path` option
122122+ 2. Environment variable (if `:config_path_env` option is set)
123123+ 3. From env `$SOWER_CONFIG_FILE`
124124+ 4. XDG path: `~/.config/sower/client.json` or `/etc/sower/client.json`
125125+ """
126126+ def resolve_config_path(opts) do
127127+ explicit_path = Keyword.get(opts, :config_path)
128128+ env_var = Keyword.get(opts, :config_path_env)
129129+ default_path = default_config_path()
130130+131131+ path =
132132+ cond do
133133+ explicit_path -> explicit_path
134134+ env_var -> System.get_env(env_var, default_path)
135135+ true -> default_path
136136+ end
137137+138138+ Path.absname(path)
139139+ end
140140+141141+ @doc """
142142+ Default config file path.
143143+144144+ Uses `$SOWER_CONFIG_FILE` if set
145145+146146+ Otherwise, returns `~/.config/sower/client.json` for non-root users,
147147+ `/etc/sower/client.json` for root.
148148+ """
149149+ def default_config_path do
150150+ System.get_env(
151151+ "SOWER_CONFIG_FILE",
152152+ xdg_config_path("sower", "client.json")
153153+ )
154154+ end
155155+156156+ def xdg_config_path(app_name, filename) do
157157+ case System.get_env("USER") do
158158+ user when user != "root" ->
159159+ System.get_env("XDG_CONFIG_HOME", Path.join(System.fetch_env!("HOME"), ".config"))
160160+ |> Path.join(app_name)
161161+ |> Path.join(filename)
162162+163163+ _ ->
164164+ Path.join(["/etc", app_name, filename])
165165+ end
166166+ end
167167+168168+ def xdg_state_path(app_name) do
169169+ case System.get_env("USER") do
170170+ user when user != "root" ->
171171+ System.get_env("XDG_STATE_HOME", Path.join(System.fetch_env!("HOME"), ".local/state"))
172172+ |> Path.join(app_name)
173173+174174+ _ ->
175175+ Path.join("/var/lib", app_name)
176176+ end
177177+ end
178178+179179+ @doc """
180180+ Parse `_file` suffixes in config keys.
181181+182182+ Converts keys ending in `_file` to their base name and reads the file content.
183183+ For example, `access_token_file: "/path/to/token"` becomes `access_token: "contents"`.
184184+ """
185185+ def parse_file_values(config_map) do
186186+ config_map
187187+ |> Enum.map(fn {key, value} ->
188188+ key =
189189+ if is_atom(key) do
190190+ Atom.to_string(key)
191191+ else
192192+ key
193193+ end
194194+195195+ if not is_nil(value) and String.ends_with?(key, "_file") do
196196+ real_key = String.trim_trailing(key, "_file")
197197+ value = value |> Path.absname() |> File.read!() |> String.trim()
198198+ {real_key, value}
199199+ else
200200+ {key, value}
201201+ end
202202+ end)
203203+ |> Map.new()
204204+ end
205205+206206+ # parse subscriptions and rules
207207+ defp normalize_subscription_rules(%{"subscriptions" => subscriptions} = config)
208208+ when is_list(subscriptions) do
209209+ normalized_subscriptions =
210210+ Enum.map(subscriptions, fn subscription ->
211211+ case subscription do
212212+ %{"rules" => rules} when is_list(rules) ->
213213+ normalized_rules =
214214+ Enum.map(rules, fn rule ->
215215+ case rule do
216216+ rule when is_binary(rule) ->
217217+ SowerClient.SubscriptionRuleFormat.parse!(rule)
218218+219219+ rule when is_map(rule) ->
220220+ rule
221221+ end
222222+ end)
223223+224224+ Map.put(subscription, "rules", normalized_rules)
225225+226226+ subscription ->
227227+ subscription
228228+ end
229229+ end)
230230+231231+ Map.put(config, "subscriptions", normalized_subscriptions)
232232+ end
233233+234234+ defp normalize_subscription_rules(config), do: config
235235+end