use trusty_mpm::client::DaemonClient;
use trusty_mpm::client::http_client::projects::{ProjectStatusWire, RegisterProjectArgs};
use trusty_mpm::project::Project;
use trusty_mpm::project_config::{self, ConfigEdit};
use crate::cli::ConfigAction;
pub(crate) struct RegisterInput {
pub name: String,
pub repo_url: String,
pub default_branch: Option<String>,
pub description: Option<String>,
pub tags: Vec<String>,
pub stack_hint: Option<String>,
pub gh_user: Option<String>,
pub gh_account: Option<String>,
pub gh_config_dir: Option<std::path::PathBuf>,
}
fn daemon(client: &reqwest::Client, url: &str) -> DaemonClient {
DaemonClient::with_client(client.clone(), url.to_string())
}
pub(crate) async fn list(
client: &reqwest::Client,
url: &str,
json: bool,
tag: Option<String>,
) -> anyhow::Result<()> {
let projects = daemon(client, url)
.registry_list_projects(tag.as_deref())
.await?;
if json {
println!("{}", serde_json::to_string_pretty(&projects)?);
return Ok(());
}
if projects.is_empty() {
println!("no projects registered");
return Ok(());
}
for p in &projects {
println!("{}", render_project_line(p));
}
Ok(())
}
pub(crate) async fn register(
client: &reqwest::Client,
url: &str,
input: RegisterInput,
) -> anyhow::Result<()> {
let previous_gh_account = daemon(client, url)
.registry_get_project(&input.name)
.await
.ok()
.and_then(|p| p.gh_account);
let new_gh_account = input.gh_account.clone();
let name_for_log = input.name.clone();
let args = RegisterProjectArgs {
name: input.name,
repo_url: input.repo_url,
default_branch: input.default_branch,
description: input.description,
tags: if input.tags.is_empty() {
None
} else {
Some(input.tags)
},
stack_hint: input.stack_hint,
gh_user: input.gh_user,
gh_account: input.gh_account,
gh_config_dir: input.gh_config_dir,
worktree: None,
};
let project = daemon(client, url).registry_register_project(&args).await?;
println!(
"registered project '{}' ({} @ {})",
project.name, project.repo_url, project.default_branch
);
if let Some(line) = account_change_log_line(
&name_for_log,
previous_gh_account.as_deref(),
new_gh_account.as_deref(),
) {
tracing::info!("{line}");
}
Ok(())
}
fn account_change_log_line(
name: &str,
previous: Option<&str>,
new: Option<&str>,
) -> Option<String> {
match (previous, new) {
(Some(old), Some(new)) if old != new => Some(format!(
"tm: project '{name}' gh_account changed: '{old}' -> '{new}'"
)),
_ => None,
}
}
pub(crate) async fn auto_persist_account_selection(
client: &reqwest::Client,
url: &str,
name: String,
repo_url: String,
account: &str,
warning_context: &str,
) {
let (gh_config_dir, config_dir_warning) = resolve_account_gh_config_dir(
trusty_mpm::daemon::managed_routes::inproject::account_config_dir_for(account),
);
if let Some(w) = config_dir_warning {
eprintln!(
"warning: {warning_context}, but could not build its per-account gh config \
directory to persist on the project registration: {w}. Later spawns for this \
project will fall back to the less reliable `gh auth token -u` selector."
);
}
let default_branch = current_default_branch(client, url, &name).await;
if let Err(e) = register(
client,
url,
RegisterInput {
name,
repo_url,
default_branch,
description: None,
tags: Vec::new(),
stack_hint: None,
gh_user: None,
gh_account: Some(account.to_string()),
gh_config_dir,
},
)
.await
{
eprintln!(
"warning: {warning_context}, but could not persist that account on the project \
registration: {e}. Later spawns for this project will not automatically reuse it \
โ pin it by hand with `tm projects register --gh-account {account} โฆ`."
);
}
}
fn resolve_account_gh_config_dir(
build_result: Result<std::path::PathBuf, String>,
) -> (Option<std::path::PathBuf>, Option<String>) {
match build_result {
Ok(dir) => (Some(dir), None),
Err(message) => (None, Some(message)),
}
}
async fn current_default_branch(client: &reqwest::Client, url: &str, name: &str) -> Option<String> {
let existing = daemon(client, url).registry_get_project(name).await.ok();
default_branch_to_preserve(existing.as_ref())
}
fn default_branch_to_preserve(existing: Option<&Project>) -> Option<String> {
existing.map(|p| p.default_branch.clone())
}
pub(crate) async fn show(
client: &reqwest::Client,
url: &str,
name: &str,
json: bool,
) -> anyhow::Result<()> {
let daemon = daemon(client, url);
let project = daemon.registry_get_project(name).await?;
let sessions = match daemon.fleet_managed_sessions().await {
Ok(groups) => groups
.into_iter()
.find(|g| g.project_name == project.name)
.map(|g| g.sessions)
.unwrap_or_default(),
Err(e) => {
eprintln!("warning: could not read sessions: {e}");
Vec::new()
}
};
if json {
let out = serde_json::json!({
"project": project,
"sessions": sessions.iter().map(|s| serde_json::json!({
"id": s.id, "name": s.name, "state": s.state,
"branch": s.branch, "task": s.task,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
println!("{}", render_project_line(&project));
if let Some(desc) = &project.description {
println!(" description: {desc}");
}
if !project.tags.is_empty() {
println!(" tags: {}", project.tags.join(", "));
}
if let Some(gh) = &project.gh_user {
println!(" gh_user: {gh}");
}
if let Some(gh) = &project.gh_account {
println!(" gh_account: {gh}");
}
println!(
" sessions ({}) [read-only โ mutate via `tm sessions <verb>`]:",
sessions.len()
);
if sessions.is_empty() {
println!(" (none)");
} else {
for s in &sessions {
let branch = s.branch.as_deref().unwrap_or("-");
println!(" {} {} [{}] {}", s.id, s.name, s.state, branch);
}
}
Ok(())
}
pub(crate) async fn status(
client: &reqwest::Client,
url: &str,
name: &str,
json: bool,
) -> anyhow::Result<()> {
let status = daemon(client, url).project_status(name).await?;
if json {
let out = serde_json::json!({
"project_name": status.project_name,
"repo_url": status.repo_url,
"sessions": {
"provisioning": status.sessions.provisioning,
"active": status.sessions.active,
"stopped": status.sessions.stopped,
"errored": status.sessions.errored,
"decommissioned": status.sessions.decommissioned,
"total": status.sessions.total,
},
"last_activity_at": status.last_activity_at,
"config": {
"gh_user_set": status.config.gh_user_set,
"github_binding_set": status.config.github_binding_set,
"gh_account_set": status.config.gh_account_set,
},
});
println!("{}", serde_json::to_string_pretty(&out)?);
return Ok(());
}
for line in render_status(&status) {
println!("{line}");
}
Ok(())
}
pub(crate) async fn config(
client: &reqwest::Client,
url: &str,
name: &str,
json: bool,
action: Option<ConfigAction>,
) -> anyhow::Result<()> {
match action {
None => config_view(client, url, name, json).await,
Some(ConfigAction::Set {
field: crate::cli::SettableConfigField::Worktree,
value,
}) => {
let normalized = normalize_bool_flag(&value)?;
config_apply(
client,
url,
name,
ConfigEdit::Set(project_config::ConfigField::Worktree, normalized),
)
.await
}
Some(ConfigAction::Set { field, value }) => {
config_apply(client, url, name, ConfigEdit::Set(field.into(), value)).await
}
Some(ConfigAction::Unset { field }) => {
config_apply(client, url, name, ConfigEdit::Unset(field.into())).await
}
Some(ConfigAction::Tags { add, remove }) => {
if add.is_empty() && remove.is_empty() {
anyhow::bail!("tags: provide --add and/or --remove");
}
config_apply(client, url, name, ConfigEdit::Tags { add, remove }).await
}
}
}
async fn config_view(
client: &reqwest::Client,
url: &str,
name: &str,
json: bool,
) -> anyhow::Result<()> {
let project = daemon(client, url).registry_get_project(name).await?;
if json {
println!("{}", serde_json::to_string_pretty(&project)?);
return Ok(());
}
println!("{}", render_config_view(&project));
Ok(())
}
async fn config_apply(
client: &reqwest::Client,
url: &str,
name: &str,
edit: ConfigEdit,
) -> anyhow::Result<()> {
let args = project_config::build_patch_args(&edit);
let project = daemon(client, url)
.registry_patch_project(name, &args)
.await?;
println!("updated project '{}'", project.name);
println!("{}", render_config_view(&project));
Ok(())
}
fn render_config_view(p: &Project) -> String {
let lines = [
format!("{} ({})", p.name, p.repo_url),
format!(" default_branch: {}", p.default_branch),
format!(
" description: {}",
p.description.as_deref().unwrap_or("(unset)")
),
format!(
" tags: {}",
if p.tags.is_empty() {
"(none)".to_string()
} else {
p.tags.join(", ")
}
),
format!(
" stack_hint: {}",
p.stack_hint.as_deref().unwrap_or("(unset)")
),
format!(" gh_user: {}", p.gh_user.as_deref().unwrap_or("(unset)")),
format!(
" gh_account: {}",
p.gh_account.as_deref().unwrap_or("(unset)")
),
format!(
" worktree: {}",
if p.worktree_enabled() {
"true (isolated per-session worktree)"
} else {
"false (launch on main โ #3455)"
}
),
];
lines.join("\n")
}
fn normalize_bool_flag(value: &str) -> anyhow::Result<String> {
match value.trim().to_ascii_lowercase().as_str() {
"true" => Ok("true".to_string()),
"false" => Ok("false".to_string()),
other => anyhow::bail!("worktree: expected 'true' or 'false', got {other:?}"),
}
}
pub(crate) fn render_project_line(p: &Project) -> String {
format!("{}\t{}\t({})", p.name, p.repo_url, p.default_branch)
}
fn render_status(s: &ProjectStatusWire) -> Vec<String> {
let c = &s.sessions;
let activity = s
.last_activity_at
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "never".to_string());
vec![
format!("{} ({})", s.project_name, s.repo_url),
format!(
" sessions: {} total ({} active, {} provisioning, {} stopped, {} errored, {} decommissioned)",
c.total, c.active, c.provisioning, c.stopped, c.errored, c.decommissioned
),
format!(" last activity: {activity}"),
format!(
" config: gh_user {}, gh_account {}, gh-binding {}",
if s.config.gh_user_set { "set" } else { "unset" },
if s.config.gh_account_set {
"set"
} else {
"unset"
},
if s.config.github_binding_set {
"set"
} else {
"unset"
}
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_mpm::client::http_client::projects::{
ProjectConfigFlagsWire, SessionStateCountsWire,
};
fn project() -> Project {
Project {
name: "widget".into(),
repo_url: "https://github.com/acme/widget".into(),
default_branch: "main".into(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
gh_account: None,
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
#[test]
fn render_project_line_basic() {
let line = render_project_line(&project());
assert!(line.contains("widget"));
assert!(line.contains("https://github.com/acme/widget"));
assert!(line.contains("main"));
}
#[test]
fn normalize_bool_flag_accepts_true_false_case_insensitive() {
assert_eq!(normalize_bool_flag("true").unwrap(), "true");
assert_eq!(normalize_bool_flag("FALSE").unwrap(), "false");
assert_eq!(normalize_bool_flag(" True ").unwrap(), "true");
}
#[test]
fn normalize_bool_flag_rejects_other_values() {
assert!(normalize_bool_flag("yes").is_err());
assert!(normalize_bool_flag("1").is_err());
assert!(normalize_bool_flag("").is_err());
}
#[test]
fn render_config_view_shows_worktree_status() {
let mut p = project();
assert!(
render_config_view(&p).contains("worktree: true"),
"default (unset) worktree must render as the true/isolated default"
);
p.worktree = Some(false);
assert!(
render_config_view(&p).contains("worktree: false"),
"an explicit opt-out must render as false"
);
}
#[test]
fn render_status_line_counts() {
let s = ProjectStatusWire {
project_name: "widget".into(),
repo_url: "https://github.com/acme/widget".into(),
sessions: SessionStateCountsWire {
provisioning: 1,
active: 2,
stopped: 0,
errored: 1,
decommissioned: 0,
total: 4,
},
last_activity_at: Some(
chrono::DateTime::parse_from_rfc3339("2026-07-10T12:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc),
),
config: ProjectConfigFlagsWire {
gh_user_set: true,
github_binding_set: false,
gh_account_set: true,
},
};
let lines = render_status(&s);
assert!(lines[0].contains("widget"));
assert!(lines[1].contains("4 total"));
assert!(lines[1].contains("2 active"));
assert!(lines[2].contains("2026-07-10"));
assert!(lines[3].contains("gh_user set"));
assert!(lines[3].contains("gh_account set"));
}
#[test]
fn render_config_view_shows_every_field() {
let mut p = project();
p.description = Some("the widget".to_string());
p.tags = vec!["backend".to_string()];
p.stack_hint = Some("rust".to_string());
p.gh_user = Some("acme-bot".to_string());
p.gh_account = Some("acme-bot".to_string());
let rendered = render_config_view(&p);
assert!(rendered.contains("default_branch: main"));
assert!(rendered.contains("description: the widget"));
assert!(rendered.contains("tags: backend"));
assert!(rendered.contains("stack_hint: rust"));
assert!(rendered.contains("gh_user: acme-bot"));
assert!(rendered.contains("gh_account: acme-bot"));
}
#[test]
fn render_config_view_shows_unset_placeholders() {
let rendered = render_config_view(&project());
assert!(rendered.contains("description: (unset)"));
assert!(rendered.contains("tags: (none)"));
assert!(rendered.contains("stack_hint: (unset)"));
assert!(rendered.contains("gh_user: (unset)"));
assert!(rendered.contains("gh_account: (unset)"));
}
#[test]
fn render_status_no_activity() {
let s = ProjectStatusWire {
project_name: "widget".into(),
repo_url: "u".into(),
sessions: SessionStateCountsWire::default(),
last_activity_at: None,
config: ProjectConfigFlagsWire::default(),
};
let lines = render_status(&s);
assert!(lines[2].contains("never"));
assert!(lines[3].contains("gh_user unset"));
}
#[test]
fn account_change_log_line_logs_a_real_change() {
let line = account_change_log_line("widget", Some("bobmatnyc"), Some("bob-duetto"))
.expect("a real change must log");
assert!(line.contains("widget"), "{line}");
assert!(line.contains("'bobmatnyc' -> 'bob-duetto'"), "{line}");
}
#[test]
fn account_change_log_line_silent_when_unchanged() {
assert_eq!(
account_change_log_line("widget", Some("bobmatnyc"), Some("bobmatnyc")),
None
);
}
#[test]
fn account_change_log_line_silent_when_previously_unset() {
assert_eq!(
account_change_log_line("widget", None, Some("bobmatnyc")),
None
);
}
#[test]
fn account_change_log_line_silent_when_new_is_unset() {
assert_eq!(
account_change_log_line("widget", Some("bobmatnyc"), None),
None
);
}
#[test]
fn resolve_account_gh_config_dir_persists_a_built_dir() {
let dir = std::path::PathBuf::from("/home/bob/.trusty-mpm/gh-accounts/bob-duetto");
let (persisted, warning) = resolve_account_gh_config_dir(Ok(dir.clone()));
assert_eq!(persisted, Some(dir));
assert!(warning.is_none());
}
#[test]
fn resolve_account_gh_config_dir_warns_and_omits_on_failure() {
let (persisted, warning) = resolve_account_gh_config_dir(Err("not logged in".to_string()));
assert!(persisted.is_none());
assert_eq!(warning.as_deref(), Some("not logged in"));
}
#[test]
fn default_branch_to_preserve_keeps_existing() {
let mut p = project();
p.default_branch = "develop".into();
assert_eq!(
default_branch_to_preserve(Some(&p)),
Some("develop".to_string())
);
}
#[test]
fn default_branch_to_preserve_is_none_for_a_new_project() {
assert_eq!(default_branch_to_preserve(None), None);
}
}