fix(cli): persist auth login target

When auth login succeeds and no CLI target is configured, write the resolved server target to settings.toml so later CLI commands can reuse it.
This commit is contained in:
Bryan Helmkamp 2026-04-29 08:02:50 -04:00
parent 90c4bd84c9
commit 85c763d511
No known key found for this signature in database
6 changed files with 154 additions and 0 deletions

1
Cargo.lock generated
View file

@ -1717,6 +1717,7 @@ dependencies = [
"tokio",
"tokio-util",
"toml 0.8.23",
"toml_edit",
"tracing",
"tracing-appender",
"tracing-subscriber",

View file

@ -68,6 +68,7 @@ fs2.workspace = true
serde.workspace = true
thiserror.workspace = true
toml.workspace = true
toml_edit.workspace = true
futures.workspace = true
regex.workspace = true
semver.workspace = true

View file

@ -111,6 +111,10 @@ impl CommandContext {
&self.user_settings
}
pub(crate) fn base_config_path(&self) -> &Path {
&self.base_config_path
}
pub(crate) fn json_output(&self) -> bool {
self.user_settings.cli.output.format == OutputFormat::Json
}

View file

@ -49,6 +49,7 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext
logged_in_at: Utc::now(),
}),
)?;
configure_cli_target_after_login(base_ctx, &target)?;
fabro_util::printerr!(printer, "Logged in to {} with dev-token", target);
return Ok(());
}
@ -113,11 +114,24 @@ pub(super) async fn login_command(args: AuthLoginArgs, base_ctx: &CommandContext
};
let summary = identity_summary(&entry.subject);
AuthStore::default().put(&target, AuthEntry::OAuth(entry))?;
configure_cli_target_after_login(base_ctx, &target)?;
fabro_util::printerr!(printer, "Logged in to {} as {}", target, summary);
Ok(())
}
}
fn configure_cli_target_after_login(
base_ctx: &CommandContext,
target: &ServerTarget,
) -> Result<()> {
user_config::configure_cli_target_if_missing(
base_ctx.base_config_path(),
base_ctx.user_settings(),
target,
)?;
Ok(())
}
#[cfg(unix)]
fn browser_origin(target: &ServerTarget) -> Result<&str> {
if target.as_unix_socket_path().is_some() {

View file

@ -14,6 +14,7 @@ use fabro_types::settings::server::LogDestination;
use fabro_types::settings::{CliNamespace, InterpString, RunNamespace};
use fabro_types::{ServerSettings, UserSettings};
use fabro_util::version::FABRO_VERSION;
use toml_edit::{DocumentMut, Item, Table, value};
use tracing::debug;
use crate::args::ServerTargetArgs;
@ -260,6 +261,80 @@ pub(crate) fn resolve_server_target(
Ok(resolve_nondefault_server_target(args, settings)?.unwrap_or_else(default_server_target))
}
#[expect(
clippy::disallowed_methods,
reason = "CLI auth/login updates the user settings file synchronously after successful login."
)]
pub(crate) fn configure_cli_target_if_missing(
config_path: &Path,
settings: &UserSettings,
target: &ServerTarget,
) -> Result<bool> {
if settings.cli.target.is_some() {
return Ok(false);
}
let mut document = read_settings_document_for_write(config_path)?;
if document
.get("cli")
.and_then(Item::as_table)
.is_some_and(|cli| cli.get("target").is_some_and(|target| !target.is_none()))
{
return Ok(false);
}
if document.get("_version").is_none() {
document["_version"] = value(1);
}
let cli = ensure_document_table(&mut document, "cli")?;
cli.insert("target", Item::Table(cli_target_table(target)));
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
std::fs::write(config_path, document.to_string())
.with_context(|| format!("failed to write {}", config_path.display()))?;
Ok(true)
}
fn read_settings_document_for_write(config_path: &Path) -> Result<DocumentMut> {
if !config_path.exists() {
return Ok(DocumentMut::new());
}
let contents = std::fs::read_to_string(config_path)
.with_context(|| format!("failed to read {}", config_path.display()))?;
contents
.parse::<DocumentMut>()
.with_context(|| format!("failed to parse {}", config_path.display()))
}
fn ensure_document_table<'a>(document: &'a mut DocumentMut, key: &str) -> Result<&'a mut Table> {
if document.get(key).is_none() {
let mut table = Table::new();
table.set_implicit(true);
document[key] = Item::Table(table);
}
document[key]
.as_table_mut()
.ok_or_else(|| anyhow!("settings.toml [{key}] is not a table"))
}
fn cli_target_table(target: &ServerTarget) -> Table {
let mut table = Table::new();
if let Some(url) = target.as_http_url() {
table.insert("type", value("http"));
table.insert("url", value(url));
} else if let Some(path) = target.as_unix_socket_path() {
table.insert("type", value("unix"));
table.insert("path", value(path.display().to_string()));
}
table
}
pub(crate) fn exec_server_target(args: &ServerTargetArgs) -> Result<Option<ServerTarget>> {
let target = explicit_server_target(args)?;
debug!(?target, "Resolved exec server target");
@ -428,6 +503,58 @@ url = "https://config.example.com"
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "unit test writes a temporary settings fixture with sync std::fs"
)]
fn configure_cli_target_creates_settings_file_when_missing() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join(".fabro").join("settings.toml");
let settings = UserSettings::default();
let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap();
assert!(configure_cli_target_if_missing(&config_path, &settings, &target).unwrap());
let contents = std::fs::read_to_string(&config_path).unwrap();
insta::assert_snapshot!(contents, @r#"
_version = 1
[cli.target]
type = "http"
url = "http://127.0.0.1:32276"
"#);
let settings = UserSettingsBuilder::load_from(&config_path).unwrap();
assert_eq!(
resolve_server_target(&server_target_args(None), &settings).unwrap(),
target
);
}
#[test]
#[expect(
clippy::disallowed_methods,
reason = "unit test writes a temporary settings fixture with sync std::fs"
)]
fn configure_cli_target_does_not_overwrite_existing_target() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("settings.toml");
let existing = r#"
_version = 1
[cli.target]
type = "http"
url = "https://configured.example.com"
"#;
std::fs::write(&config_path, existing).unwrap();
let settings = UserSettingsBuilder::load_from(&config_path).unwrap();
let target = ServerTarget::http_url("https://new.example.com").unwrap();
assert!(!configure_cli_target_if_missing(&config_path, &settings, &target).unwrap());
assert_eq!(std::fs::read_to_string(&config_path).unwrap(), existing);
}
#[test]
fn storage_dir_defaults_without_server_auth_methods() {
let document = toml::Value::Table(toml::Table::new());

View file

@ -68,6 +68,9 @@ fn login_help() {
)]
fn login_with_dev_token_writes_auth_store_entry() {
let context = test_context!();
let settings_file = context.home_dir.join(".fabro").join("settings.toml");
std::fs::write(&settings_file, "_version = 1\n").unwrap();
let mut cmd = context.command();
cmd.args([
"auth",
@ -91,6 +94,10 @@ fn login_with_dev_token_writes_auth_store_entry() {
let entry = &auth["servers"]["http://127.0.0.1:32276"];
assert_eq!(entry["kind"], "dev-token");
assert_eq!(entry["token"], DEV_TOKEN);
let settings = std::fs::read_to_string(settings_file).unwrap();
assert!(settings.contains("[cli.target]"));
assert!(settings.contains("http://127.0.0.1:32276"));
}
#[test]