use std::path::Path;
use std::time::Duration;
use anyhow::{Result, bail};
use crate::job::{self, Mode};
use crate::model::App;
use crate::store;
const READY_PATIENCE: Duration = Duration::from_secs(90);
const PATIENCE_ENV: &str = "TURNOUT_READY_PATIENCE_MS";
pub(crate) fn ready_patience() -> Duration {
std::env::var(PATIENCE_ENV)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.map_or(READY_PATIENCE, Duration::from_millis)
}
#[derive(Clone, Copy, Default)]
pub struct Options {
pub verbose: bool,
pub open: bool,
pub detach: bool,
}
pub fn run(command_name: &str, app_name: Option<String>, options: Options) -> Result<()> {
let mut apps = store::load_apps()?;
let index = {
let app = resolve(&apps, app_name)?;
apps.iter().position(|a| a.name == app.name).expect("resolved from the same list")
};
if command_name == "dev" && apps[index].dev_port.is_none() {
let Some(port) = crate::front::dev_port_for(&apps, &apps[index].name) else {
bail!(
"no free dev port left in {:?} - pin one with `turnout app edit {} --dev-port PORT`",
crate::front::DEV_PORTS,
apps[index].name
);
};
apps[index].dev_port = Some(port);
store::save_apps(&apps)?;
}
let app = &apps[index];
let Some(command_line) = app.commands.get(command_name) else {
bail!(
"app '{0}' has no '{1}' command - add it with `turnout app edit {0} --command {1}=CMD`",
app.name,
command_name
);
};
let dir = crate::utils::project_dir(Path::new(&app.path))?;
let command_line = crate::envfile::substitute(command_line, app)?;
job::ensure_free(&crate::registry::command_key(&app.name, command_name))?;
let mut env = Vec::new();
if !matches!(command_name, "build" | "test" | "lint")
&& let Some(url) = app.gateway_url()
{
env.push((app.gateway_env_name(), url));
}
let long_running = !matches!(command_name, "build" | "test" | "lint");
let mode = Mode::resolve(if long_running { Mode::UntilReady } else { Mode::Quiet }, options.verbose);
eprintln!("[{}] {command_line}", app.name);
let door = crate::registry::gateway()?.and_then(|gateway| gateway.front_port);
if command_name == "dev"
&& let Some(port) = app.dev_port
{
env.push(("PORT", port.to_string()));
match door {
Some(front) => eprintln!("[{}] {} -> dev server port {port}", app.name, crate::front::address(&app.name, front)),
None => eprintln!(
"[{}] dev server port {port} (PORT, {{port}}) - start the gateway for http://{}.localhost",
app.name, app.name
),
}
if !command_line.contains(&port.to_string()) && !app.commands["dev"].contains("{port}") {
eprintln!(
"[{}] note: the dev command does not mention {{port}} - a server that ignores PORT stays on its own port",
app.name
);
}
}
if options.open && !long_running {
eprintln!("[{}] note: --open waits for a server to come up; '{command_name}' is not one", app.name);
}
let label = label_for(command_name, &app.name);
let own = app.dev_port.map(|port| format!("http://localhost:{port}"));
if options.detach {
return crate::commands::jobs::detach(crate::commands::jobs::Detach {
app: &app.name,
command: command_name,
dir: &dir,
env: &env,
label: &label,
ready: long_running,
own,
open: options.open && long_running,
itself: false,
program: vec![command_line],
});
}
let front_door = door.map(|front| crate::front::address(&app.name, front));
let ready = long_running.then(|| job::Ready {
app: &app.name,
door: front_door.clone(),
own,
patience: ready_patience(),
on_ready: options.open.then(|| opener(front_door)),
});
let mut job = job::Job::claim(&app.name, command_name, false)?;
let outcome = job::run(job::Program::Shell(&command_line), &dir, &env, mode, &label, &mut job, ready)?;
let code = if crate::term::interrupted() {
130
} else {
outcome.status.code().unwrap_or(1)
};
std::process::exit(code);
}
pub(crate) fn opener(front_door: Option<String>) -> Box<dyn Fn() + Send> {
Box::new(move || match &front_door {
Some(url) => {
if let Err(err) = crate::front::open_in_browser(url) {
eprintln!("note: cannot open {url}: {err:#}");
}
}
None => eprintln!("note: --open needs the gateway's front door - start it with `turnout gateway start`"),
})
}
fn label_for(command_name: &str, app: &str) -> String {
let verb = match command_name {
"build" => "Building",
"test" => "Testing",
"lint" => "Linting",
"dev" => "Starting",
other => return format!("Running {other} for {app}"),
};
format!("{verb} {app}")
}
pub(crate) fn resolve(apps: &[App], name: Option<String>) -> Result<&App> {
match name {
Some(name) => apps
.iter()
.find(|a| a.name == name)
.ok_or_else(|| anyhow::anyhow!("no app named '{name}' - see `turnout app list`")),
None => {
if let Some(app) = app_here(apps)? {
return Ok(app);
}
crate::pick::ensure_interactive("not inside a known app directory - pass the app name or see `turnout app list`")?;
let picked = crate::pick::app(apps, &store::load_state()?, "App")?;
apps.iter().find(|a| a.name == picked).ok_or_else(|| anyhow::anyhow!("no app named '{picked}'"))
}
}
}
pub(crate) fn app_here(apps: &[App]) -> Result<Option<&App>> {
let cwd = std::env::current_dir()?;
let cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
Ok(apps
.iter()
.filter(|a| {
let path = Path::new(&a.path);
let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
cwd.starts_with(&path)
})
.max_by_key(|a| a.path.len()))
}
#[cfg(test)]
mod tests {
use super::label_for;
#[test]
fn the_loader_names_the_job_as_an_action() {
assert_eq!(label_for("build", "myapp"), "Building myapp");
assert_eq!(label_for("test", "myapp"), "Testing myapp");
assert_eq!(label_for("lint", "myapp"), "Linting myapp");
assert_eq!(label_for("dev", "myapp"), "Starting myapp");
assert_eq!(label_for("storybook", "myapp"), "Running storybook for myapp");
}
}