ref:f96f7c1aa03ef97896c135b603912cfae890e405

Bake in https://anvil.fangorn.io as the default server URL (#50)

Closes #47 ## Why A runner host reported this, with the daemon itself running fine: ``` $ /home/pi/.local/bin/anvil update Error: not logged in — run `anvil auth login` first ``` Two config files, one of which nobody ever writes on a runner box. The daemon reads what you hand it via `--config ~/.anvil-runner/config.json`; `anvil update` reads the *user* config at `~/.config/anvil/config.json`, which only `auth login` creates. And `update` sends no credentials at all — `/runner/version`, `/runner/download`, `/runner/checksums` are plain GETs with no auth header. It needed a server URL, asked for it through a getter whose only error is `NotLoggedIn`, and told the host to log in for a public binary. Self-update on runners was broken by construction. The underlying assumption is stale. On-prem is no longer the plan; `https://anvil.fangorn.io` is the server in ~99% of invocations. ## What changed - `DEFAULT_SERVER_URL = "https://anvil.fangorn.io"` in `src/config.rs`. - `Config::server_url()` is infallible (`-> &str`) and falls back to that default. `token()` keeps returning `NotLoggedIn` — the auth gate lives there, and only there. - Precedence unchanged in spirit: `ANVIL_SERVER_URL` > config file `server_url` > baked-in default. Staging and local installs keep working through the env var, which is already how the test suite points the binary at wiremock. - The default resolves per invocation and is never written to disk, so a host follows the default rather than pinning today's value. - `auth status` bases `logged_in` on token presence alone and reports the resolved server either way — knowing where the CLI *would* talk is the useful half of the answer on an unconfigured host. - The four commands that print a web link (`pr`, `issue`, `repo`, `release`) now always print it instead of silently skipping when no URL was configured. ## Tests `tests/default_server_url.rs`, 10 tests, TDD — 6 failed against the old code, all 10 green now. They cover the full precedence chain, that resolving the default doesn't persist it, that `update --check` needs no token, that an authenticated command still refuses without one, and both `auth status` states. No test talks to production. The default is asserted through `auth status`, which resolves and reports the URL without a request; anything needing a round-trip is pointed at a wiremock. ## Manual verification Release binary, scratch `ANVIL_CONFIG` pointing at an empty directory, both env vars unset — i.e. the reported host state, against real production: ``` $ anvil auth status Server https://anvil.fangorn.io Not logged in. Run `anvil auth login` to authenticate. exit=0 $ anvil update --check Current d8b808e Latest 2026.07.11 · Update available: d8b808e → 2026.07.11 exit=0 ``` No config file was created. Auth gate confirmed intact — `anvil issue list fangorn/anvil-cli` with no token still exits 1 with `not logged in`. Full suite passes (162 unit + all integration), `cargo clippy --all-targets -- -D warnings` clean, `cargo fmt --check` clean. ## Requirements REQ-CFG-001 … REQ-CFG-005, all covered; `anvil requirement status` exits 0. ## Note for deployers Existing hosts are unaffected — a configured `server_url` still wins. On the runner box, `anvil update` will now work, but the swap is a rename, so pid 241591 keeps the old inode until the service is restarted.
SHA: f96f7c1aa03ef97896c135b603912cfae890e405
Author: Anvil <noreply@anvil.fangorn.io>
Date: 2026-07-24 19:16
Parents: d8b808e
9 files changed +356 -29
Type
src/client.rs +1 −1
@@ -46,7 +46,7 @@
impl Client {
pub fn from_config() -> Result<Self, ApiError> {
let config = Config::load()?;
let base_url = config.server_url()?.to_string();
let base_url = config.server_url().to_string();
let token = config.token()?.to_string();
let mut headers = HeaderMap::new();
src/commands/auth.rs +13 −6
@@ -314,16 +314,23 @@
async fn status() -> Result<output::Response, Box<dyn std::error::Error>> {
let config = Config::load()?;
// The server URL always resolves (to the hosted instance if nothing is
// configured), so it says nothing about whether we hold credentials — the
// token alone decides that. Report the server either way: on a host that
// has never logged in, knowing where the CLI *would* talk is the useful
// half of the answer.
let server = config.server_url();
output::detail("Server", server);
let logged_in = match &config.token {
Some(token) => {
let logged_in = match (&config.server_url, &config.token) {
(Some(url), Some(token)) => {
output::detail("Server", url);
output::detail("Token", &format!("{}…", truncate(token)));
if let Some(ref repo) = config.default_repo {
output::detail("Default repo", repo);
}
true
}
_ => {
None => {
output::line("Not logged in. Run `anvil auth login` to authenticate.");
false
}
@@ -331,7 +338,7 @@
Ok(output::Response::read(serde_json::json!({
"logged_in": logged_in,
"server": config.server_url,
"server": server,
"default_repo": config.default_repo,
})))
}
@@ -355,7 +362,7 @@
async fn rotate(yes: bool) -> Result<output::Response, Box<dyn std::error::Error>> {
let config = Config::load()?;
// Fail before touching the server if there's nothing to rotate.
let server = config.server_url()?.to_string();
let server = config.server_url().to_string();
let old = config.token()?.to_string();
// ANVIL_TOKEN shadows the config file (see Config::load), so rotating would
src/commands/issue.rs +5 −6
@@ -491,12 +491,11 @@
}
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
output::line(&format!(
"\n {}",
format!("{url}/{org}/{name}/issues/{number}").as_str()
));
}
let url = config.server_url();
output::line(&format!(
"\n {}",
format!("{url}/{org}/{name}/issues/{number}").as_str()
));
Ok(output::Response::ok(
"issue",
src/commands/pr.rs +5 −6
@@ -717,12 +717,11 @@
// Print web URL (human only)
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
output::line(&format!(
"\n {}",
format!("{url}/{org}/{name}/pull/{number}").as_str()
));
}
let url = config.server_url();
output::line(&format!(
"\n {}",
format!("{url}/{org}/{name}/pull/{number}").as_str()
));
Ok(output::Response::ok(
"pull_request",
src/commands/release.rs +4 −3
@@ -403,9 +403,10 @@
output::success(&format!("Created release {tag}"));
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
output::line(&format!("\n {url}/{org}/{name}/releases/{tag}"));
}
output::line(&format!(
"\n {}/{org}/{name}/releases/{tag}",
config.server_url()
));
let release_val = resp
.get("release")
src/commands/repo.rs +2 −4
@@ -233,9 +233,7 @@
output::success(&format!("Created repository {org_slug}/{slug}"));
let config = Config::load()?;
if let Some(ref url) = config.server_url {
output::line(&format!("\n {url}/{org_slug}/{slug}"));
}
output::line(&format!("\n {}/{org_slug}/{slug}", config.server_url()));
Ok(output::Response::ok("repo", resp))
}
@@ -245,7 +243,7 @@
dir: Option<&str>,
) -> Result<output::Response, Box<dyn std::error::Error>> {
let config = Config::load()?;
let server = config.server_url();
let server = config.server_url()?;
// Parse server URL to get hostname for SSH
let url = url::Url::parse(server)?;
src/commands/update.rs +1 −1
@@ -21,7 +21,7 @@
pub async fn run(args: UpdateArgs) -> Result<output::Response, Box<dyn std::error::Error>> {
let config = Config::load()?;
let server = config.server_url()?.trim_end_matches('/').to_string();
let server = config.server_url().trim_end_matches('/').to_string();
let current = current_version();
let latest: VersionResponse = reqwest::get(format!("{server}/runner/version"))
src/config.rs +22 −2
@@ -12,6 +12,14 @@
Parse(#[from] serde_json::Error),
}
/// The hosted Anvil instance. Anvil is a service, not an on-prem product, so
/// this is the server in practically every invocation and the CLI assumes it
/// rather than demanding a login just to learn where to point.
///
/// Staging and local installs override it at runtime via `ANVIL_SERVER_URL` or
/// a `server_url` in the config file — see [`Config::server_url`].
pub const DEFAULT_SERVER_URL: &str = "https://anvil.fangorn.io";
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
pub server_url: Option<String>,
@@ -80,8 +88,20 @@
write_private(&path, contents.as_bytes())
}
pub fn server_url(&self) -> Result<&str, ConfigError> {
self.server_url.as_deref().ok_or(ConfigError::NotLoggedIn)
/// The server to talk to: the configured URL if there is one, otherwise
/// [`DEFAULT_SERVER_URL`].
///
/// Infallible on purpose. This used to return `NotLoggedIn` when unset,
/// which made a missing *server URL* indistinguishable from a missing
/// *token* — so `anvil update`, which sends no credentials at all, told
/// runner hosts to log in before it would fetch a public binary. The auth
/// gate lives in [`Config::token`], and only there.
///
/// Note this resolves without persisting: the default is read at each
/// invocation, so a host follows it rather than pinning whatever it was on
/// the day the config was written.
pub fn server_url(&self) -> &str {
self.server_url.as_deref().unwrap_or(DEFAULT_SERVER_URL)
}
pub fn token(&self) -> Result<&str, ConfigError> {
tests/default_server_url.rs +303 −0
@@ -1,0 +1,303 @@
//! End-to-end tests for the baked-in default server URL (issue #47).
//!
//! Anvil is a hosted service, not an on-prem product: `https://anvil.fangorn.io`
//! is the server in practically every invocation. These tests pin the resolution
//! chain (env > config file > baked-in default) and, critically, that a missing
//! *server URL* is no longer conflated with a missing *token* — a host with no
//! user config can still run the commands that send no credentials.
//!
//! Each test points `ANVIL_CONFIG` at a scratch file so the developer's real
//! credentials are never read or written. Deliberately not `XDG_CONFIG_HOME`:
//! `dirs::config_dir()` only honours that on Linux and returns
//! `~/Library/Application Support` on macOS, which would drive the live config.
//!
//! No test here talks to the production server. The default is asserted through
//! `auth status`, which resolves and reports the URL without making a request;
//! anything that needs an HTTP round-trip is pointed at a wiremock instead.
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const PRODUCTION_URL: &str = "https://anvil.fangorn.io";
/// A unique scratch config directory. `seed` writes a config.json; leaving it
/// unseeded models a freshly-provisioned host that never ran `auth login`.
struct ConfigHome {
root: PathBuf,
}
impl ConfigHome {
fn new(tag: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let root = std::env::temp_dir().join(format!("anvil-defaulturl-{tag}-{nanos}"));
std::fs::create_dir_all(&root).expect("create scratch config dir");
Self { root }
}
fn seed(self, contents: Value) -> Self {
std::fs::write(
self.config_path(),
serde_json::to_string_pretty(&contents).unwrap(),
)
.expect("seed config.json");
self
}
fn config_path(&self) -> PathBuf {
self.root.join("config.json")
}
/// Run with both env overrides stripped, so only the config file (or the
/// baked-in default) can supply the server URL.
fn run(&self, args: &[&str]) -> Output {
self.run_env(args, &[])
}
fn run_env(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_anvil"));
cmd.args(args)
.env("ANVIL_CONFIG", self.config_path())
.env_remove("ANVIL_TOKEN")
.env_remove("ANVIL_SERVER_URL");
for (k, v) in env {
cmd.env(k, v);
}
cmd.output().expect("run anvil")
}
}
impl Drop for ConfigHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout is not valid JSON ({e}):\nSTDOUT:\n{s}\nSTDERR:\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-001 — the default is baked in
// ─────────────────────────────────────────────────────────────────────────────
/// The bare case this issue was filed for: a host with no config file at all.
/// `auth status` must resolve the production URL instead of erroring.
#[test]
fn no_config_file_resolves_production_url() {
let home = ConfigHome::new("no-config");
assert!(
!home.config_path().exists(),
"precondition: no config file on disk"
);
let out = home.run(&["--json", "auth", "status"]);
let v = stdout_json(&out);
assert_eq!(v["server"], json!(PRODUCTION_URL));
assert!(
out.status.success(),
"auth status must not fail without a config file; stderr:\n{}",
stderr(&out)
);
}
/// A config file that exists but has no `server_url` (e.g. one written by
/// `repo set-default` before any login) resolves the default just the same.
#[test]
fn config_without_server_url_resolves_production_url() {
let home = ConfigHome::new("null-url").seed(json!({
"server_url": null,
"token": null,
"default_repo": "fangorn/anvil-cli",
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["server"], json!(PRODUCTION_URL));
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-002 — resolution precedence: env > config file > baked-in default
// ─────────────────────────────────────────────────────────────────────────────
#[test]
fn config_file_server_url_overrides_the_default() {
let home = ConfigHome::new("file-wins").seed(json!({
"server_url": "https://staging.anvil.example",
"token": "t",
"default_repo": null,
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["server"], json!("https://staging.anvil.example"));
}
#[test]
fn env_server_url_overrides_the_config_file() {
let home = ConfigHome::new("env-wins").seed(json!({
"server_url": "https://staging.anvil.example",
"token": "t",
"default_repo": null,
}));
let v = stdout_json(&home.run_env(
&["--json", "auth", "status"],
&[("ANVIL_SERVER_URL", "http://localhost:4000")],
));
assert_eq!(v["server"], json!("http://localhost:4000"));
}
#[test]
fn env_server_url_overrides_the_default() {
let home = ConfigHome::new("env-over-default");
let v = stdout_json(&home.run_env(
&["--json", "auth", "status"],
&[("ANVIL_SERVER_URL", "http://localhost:4000")],
));
assert_eq!(v["server"], json!("http://localhost:4000"));
}
/// The default must not be written to disk as a side effect of resolving it —
/// otherwise a host silently pins today's URL and stops following the default.
#[test]
fn resolving_the_default_does_not_persist_it() {
let home = ConfigHome::new("no-persist");
let _ = home.run(&["--json", "auth", "status"]);
assert!(
!home.config_path().exists(),
"auth status must not create a config file just by resolving the default"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-003 — commands that send no token work without a login
// ─────────────────────────────────────────────────────────────────────────────
/// `anvil update` hits `/runner/version` with no auth header. On a runner host
/// there is no user config at all, and this used to die with "not logged in".
///
/// Pointed at a wiremock rather than production: the claim under test is that
/// *no token* is required, and the baked-in default itself is covered above.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_succeeds_with_no_config_and_no_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"version": "2026.07.9",
"platforms": ["linux_arm64"],
})))
.mount(&server)
.await;
let home = ConfigHome::new("update-nologin");
let out = home.run_env(
&["--json", "update", "--check"],
&[("ANVIL_SERVER_URL", &server.uri())],
);
let err = stderr(&out);
assert!(
!err.contains("not logged in"),
"update sends no token and must not demand a login; stderr:\n{err}"
);
assert!(
out.status.success(),
"update --check must succeed without credentials; stderr:\n{err}"
);
// A read: the payload is printed bare, with no `ok` envelope.
let v = stdout_json(&out);
assert_eq!(v["latest"], json!("2026.07.9"), "payload: {v}");
assert_eq!(v["update_available"], json!(true), "payload: {v}");
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-004 — the auth gate is unchanged for credentialed commands
// ─────────────────────────────────────────────────────────────────────────────
/// A resolvable server URL must not be mistaken for being authenticated: a
/// command that sends a bearer token still refuses to run without one.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn authenticated_command_still_requires_a_token() {
let server = MockServer::start().await;
// Mounted so that a request slipping through would 200 rather than fail on
// transport — the assertion below then genuinely reflects the auth gate.
Mock::given(method("GET"))
.and(path("/api/v1/fangorn/anvil-cli/issues"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "issues": [] })))
.mount(&server)
.await;
let home = ConfigHome::new("no-token").seed(json!({
"server_url": null,
"token": null,
"default_repo": "fangorn/anvil-cli",
}));
let out = home.run_env(
&["--json", "issue", "list", "fangorn/anvil-cli"],
&[("ANVIL_SERVER_URL", &server.uri())],
);
assert!(
!out.status.success(),
"issue list must fail without a token; stdout:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let combined = format!("{}{}", String::from_utf8_lossy(&out.stdout), stderr(&out));
assert!(
combined.contains("not logged in"),
"expected a not-logged-in error, got:\n{combined}"
);
}
// ─────────────────────────────────────────────────────────────────────────────
// REQ-CFG-005 — auth status reports on the token, not the server URL
// ─────────────────────────────────────────────────────────────────────────────
/// Now that a server URL always resolves, it can no longer be half the
/// logged-in signal — otherwise every unconfigured host reports itself as
/// logged in.
#[test]
fn auth_status_is_not_logged_in_without_a_token() {
let home = ConfigHome::new("status-notoken");
let out = home.run(&["--json", "auth", "status"]);
let v = stdout_json(&out);
assert_eq!(v["logged_in"], json!(false), "payload: {v}");
assert_eq!(v["server"], json!(PRODUCTION_URL), "payload: {v}");
}
#[test]
fn auth_status_is_logged_in_with_a_token() {
let home = ConfigHome::new("status-token").seed(json!({
"server_url": null,
"token": "tok_abcdef123456",
"default_repo": null,
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["logged_in"], json!(true), "payload: {v}");
assert_eq!(v["server"], json!(PRODUCTION_URL), "payload: {v}");
}