use agent_runtime::AgentRuntime;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
pub const DEFAULT_PORT: u16 = 3000;
pub const DEFAULT_APP_NAME: &str = "caretta-dev-bot";
pub const DEFAULT_WEBHOOK_URL: &str = "https://example.com/caretta-webhook";
pub const INSTALLER_PACKAGE: &str = "caretta-github-app-installer";
const INSTALLER_ENTRYPOINT: &str = "node_modules/caretta-github-app-installer/src/cli.ts";
#[derive(Debug, Clone, Default)]
pub struct InstallerOptions {
pub port: Option<u16>,
pub app_name: Option<String>,
pub owner: Option<String>,
pub webhook_url: Option<String>,
pub working_dir: Option<PathBuf>,
}
impl InstallerOptions {
pub fn env_pairs(&self) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = Vec::new();
if let Some(port) = self.port {
env.push(("PORT".to_string(), port.to_string()));
}
push_env(&mut env, "APP_NAME", self.app_name.as_deref());
push_env(&mut env, "GITHUB_ORG", self.owner.as_deref());
push_env(&mut env, "WEBHOOK_URL", self.webhook_url.as_deref());
env
}
}
fn push_env(into: &mut Vec<(String, String)>, key: &str, value: Option<&str>) {
if let Some(v) = value {
let trimmed = v.trim();
if !trimmed.is_empty() {
into.push((key.to_string(), trimmed.to_string()));
}
}
}
#[derive(Debug, Clone)]
pub struct InstallerOutcome {
pub success: bool,
pub exit_code: Option<i32>,
pub stdout: String,
pub stderr: String,
pub working_dir: PathBuf,
}
pub fn installer_script_path(runtime: &AgentRuntime) -> PathBuf {
runtime.root().join(INSTALLER_ENTRYPOINT)
}
pub fn require_installer_script(runtime: &AgentRuntime) -> Result<PathBuf, String> {
let path = installer_script_path(runtime);
if path.is_file() {
Ok(path)
} else {
Err(format!(
"Embedded installer script not found at {}. \
Reinstall the agent runtime (`bun install` in crates/agent-runtime) \
so the `{INSTALLER_PACKAGE}` npm package is materialized.",
path.display()
))
}
}
pub fn build_install_command(opts: &InstallerOptions) -> Result<Command, String> {
let runtime = AgentRuntime::prepare()
.map_err(|e| format!("Failed to prepare embedded agent runtime: {e}"))?;
let script = require_installer_script(&runtime)?;
let mut cmd = runtime.command_for_binary("bun");
cmd.arg(script);
let cwd = opts
.working_dir
.clone()
.unwrap_or_else(|| runtime.root().to_path_buf());
cmd.current_dir(cwd);
for (k, v) in opts.env_pairs() {
cmd.env(k, v);
}
Ok(cmd)
}
pub fn run_installer_blocking(opts: &InstallerOptions) -> Result<InstallerOutcome, String> {
let mut cmd = build_install_command(opts)?;
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let working_dir = effective_working_dir(opts);
let Output {
status,
stdout,
stderr,
} = cmd
.output()
.map_err(|e| format!("Failed to spawn embedded Bun runtime: {e}"))?;
Ok(InstallerOutcome {
success: status.success(),
exit_code: status.code(),
stdout: String::from_utf8_lossy(&stdout).into_owned(),
stderr: String::from_utf8_lossy(&stderr).into_owned(),
working_dir,
})
}
fn effective_working_dir(opts: &InstallerOptions) -> PathBuf {
if let Some(dir) = opts.working_dir.as_ref() {
return dir.clone();
}
match AgentRuntime::prepare() {
Ok(rt) => rt.root().to_path_buf(),
Err(_) => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn env_pairs_skips_unset_and_blank_fields() {
let opts = InstallerOptions {
port: None,
app_name: Some(" ".to_string()),
owner: Some("".to_string()),
webhook_url: None,
working_dir: None,
};
assert!(opts.env_pairs().is_empty());
}
#[test]
fn env_pairs_emits_port_app_name_owner_and_webhook() {
let opts = InstallerOptions {
port: Some(4123),
app_name: Some(" my-bot ".to_string()),
owner: Some("my-org".to_string()),
webhook_url: Some("https://example.com/hook".to_string()),
working_dir: None,
};
let env: std::collections::HashMap<_, _> = opts.env_pairs().into_iter().collect();
assert_eq!(env.get("PORT").map(String::as_str), Some("4123"));
assert_eq!(env.get("APP_NAME").map(String::as_str), Some("my-bot"));
assert_eq!(env.get("GITHUB_ORG").map(String::as_str), Some("my-org"));
assert_eq!(
env.get("WEBHOOK_URL").map(String::as_str),
Some("https://example.com/hook")
);
}
#[test]
fn installer_entrypoint_lives_under_node_modules() {
assert!(INSTALLER_ENTRYPOINT.starts_with("node_modules/"));
assert!(INSTALLER_ENTRYPOINT.ends_with(".ts"));
assert!(INSTALLER_ENTRYPOINT.contains(INSTALLER_PACKAGE));
}
#[test]
fn installer_script_path_joins_under_runtime_root() {
let runtime = AgentRuntime::prepare().expect("agent runtime should prepare");
let script = installer_script_path(&runtime);
assert!(
script.starts_with(runtime.root()),
"script {} should sit under runtime root {}",
script.display(),
runtime.root().display()
);
assert!(
script.ends_with(INSTALLER_ENTRYPOINT),
"unexpected script suffix: {}",
script.display()
);
}
#[test]
fn require_installer_script_succeeds_for_bundled_runtime() {
let runtime = AgentRuntime::prepare().expect("agent runtime should prepare");
let script = require_installer_script(&runtime).expect("entrypoint present");
assert!(script.is_file());
}
#[test]
fn build_install_command_targets_bun_and_passes_script() {
let opts = InstallerOptions {
port: Some(4500),
..InstallerOptions::default()
};
let cmd = build_install_command(&opts).expect("command");
let program = Path::new(cmd.get_program()).to_path_buf();
let program_str = program.to_string_lossy().to_lowercase();
assert!(
program_str.contains("bun") || program == Path::new("bun"),
"expected bun-like program, got {}",
program.display()
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
args.len(),
1,
"expected exactly one positional arg: {args:?}"
);
assert!(
args[0].ends_with("/caretta-github-app-installer/src/cli.ts"),
"unexpected script arg: {}",
args[0]
);
let envs: std::collections::HashMap<String, String> = cmd
.get_envs()
.filter_map(|(k, v)| {
Some((
k.to_string_lossy().into_owned(),
v?.to_string_lossy().into_owned(),
))
})
.collect();
assert_eq!(envs.get("PORT").map(String::as_str), Some("4500"));
}
}