use std::env;
use std::io::{BufRead, BufReader};
use std::path::PathBuf;
use std::process::{self, Command, Stdio};
use start_command::{
append_log_file,
args_parser::{
generate_session_name, generate_uuid, get_effective_mode, has_isolation, parse_args,
},
build_isolation_options_map, clear_current_execution, create_finish_block, create_log_footer,
create_log_header, create_log_path_for_execution, create_start_block,
docker_runtime_status_lines,
execution_control::{control_execution, ControlAction},
execution_store::{
CleanupOptions, ExecutionRecord, ExecutionRecordOptions, ExecutionStore,
ExecutionStoreOptions,
},
failure_handler::{handle_failure, Config as FailureConfig},
get_timestamp,
isolation::{run_as_isolated_user, run_isolated, IsolationOptions},
output_blocks::{FinishBlockOptions, StartBlockOptions},
set_current_execution, setup_signal_handlers,
status_formatter::{list_executions, query_status},
substitution::{process_command, ProcessOptions},
upload_execution_log,
usage::print_usage,
user_manager::{
create_isolated_user, delete_user, get_current_user_groups, has_sudo_access,
CreateIsolatedUserOptions, DeleteUserOptions,
},
write_log_file, LogHeaderParams,
};
struct Config {
disable_auto_issue: bool,
disable_log_upload: bool,
verbose: bool,
disable_substitutions: bool,
substitutions_path: Option<String>,
use_command_stream: bool,
disable_tracking: bool,
app_folder: Option<String>,
}
impl Config {
fn from_env() -> Self {
let default_app_folder =
dirs::home_dir().map(|h| h.join(".start-command").to_string_lossy().to_string());
Self {
disable_auto_issue: env_bool("START_DISABLE_AUTO_ISSUE"),
disable_log_upload: env_bool("START_DISABLE_LOG_UPLOAD"),
verbose: env_bool("START_VERBOSE"),
disable_substitutions: env_bool("START_DISABLE_SUBSTITUTIONS"),
substitutions_path: env::var("START_SUBSTITUTIONS_PATH").ok(),
use_command_stream: env_bool("START_USE_COMMAND_STREAM"),
disable_tracking: env_bool("START_DISABLE_TRACKING"),
app_folder: env::var("START_APP_FOLDER").ok().or(default_app_folder),
}
}
fn create_execution_store(&self) -> Option<ExecutionStore> {
if self.disable_tracking {
return None;
}
let options = ExecutionStoreOptions {
verbose: self.verbose,
app_folder: self.app_folder.as_ref().map(PathBuf::from),
..ExecutionStoreOptions::default()
};
Some(ExecutionStore::with_options(options))
}
}
fn env_bool(name: &str) -> bool {
env::var(name).is_ok_and(|v| v == "1" || v == "true")
}
fn main() {
setup_signal_handlers();
let config = Config::from_env();
let args: Vec<String> = env::args().skip(1).collect();
let has_version_flag = !args.is_empty() && (args[0] == "--version" || args[0] == "-v");
let has_verbose_with_version =
has_version_flag && args.iter().any(|a| a == "--verbose" || a == "--debug");
let version_related_args = ["--version", "-v", "--", "--verbose", "--debug"];
let is_version_only = has_version_flag
&& args
.iter()
.all(|a| version_related_args.contains(&a.as_str()) || *a == args[0]);
if has_version_flag && is_version_only {
print_version(has_verbose_with_version || config.verbose);
process::exit(0);
}
if args.is_empty() {
print_usage();
process::exit(0);
}
let parsed = match parse_args(&args) {
Ok(p) => p,
Err(e) => {
eprintln!("Error: {}", e);
process::exit(1);
}
};
let wrapper_options = parsed.wrapper_options;
let parsed_command = parsed.command.clone();
if let Some(ref uuid) = wrapper_options.status {
handle_status_query(&config, uuid, wrapper_options.output_format.as_deref());
process::exit(0);
}
if wrapper_options.list {
handle_list_query(&config, wrapper_options.output_format.as_deref());
process::exit(0);
}
if let Some(ref identifier) = wrapper_options.upload_log {
process::exit(handle_upload_log_query(&config, identifier));
}
if let Some(ref identifier) = wrapper_options.stop {
handle_control_query(&config, identifier, ControlAction::Stop);
process::exit(0);
}
if let Some(ref identifier) = wrapper_options.terminate {
handle_control_query(&config, identifier, ControlAction::Terminate);
process::exit(0);
}
if wrapper_options.cleanup {
handle_cleanup(&config, wrapper_options.cleanup_dry_run);
process::exit(0);
}
if parsed_command.is_empty() {
eprintln!("Error: No command provided");
print_usage();
process::exit(1);
}
let mut command = parsed_command.clone();
let mut substitution_result = None;
if !config.disable_substitutions {
let result = process_command(
&parsed_command,
&ProcessOptions {
custom_lino_path: config.substitutions_path.clone(),
verbose: config.verbose,
},
);
if result.matched {
command = result.command.clone();
if config.verbose {
println!("[Substitution] \"{}\" -> \"{}\"", parsed_command, command);
println!();
}
substitution_result = Some(result);
}
}
let use_command_stream = wrapper_options.use_command_stream || config.use_command_stream;
let session_id = wrapper_options
.session_id
.clone()
.unwrap_or_else(generate_uuid);
if has_isolation(&wrapper_options) || wrapper_options.user {
run_with_isolation(
&config,
&wrapper_options,
&command,
use_command_stream,
&session_id,
);
} else {
run_direct(
&config,
&command,
&parsed_command,
substitution_result.as_ref(),
&session_id,
);
}
}
fn print_version(verbose: bool) {
let version = env!("CARGO_PKG_VERSION");
println!("start-command version: {} (Rust)", version);
println!();
println!("OS: {}", std::env::consts::OS);
println!("Architecture: {}", std::env::consts::ARCH);
println!();
println!("Isolation tools:");
if verbose {
println!("[verbose] Checking isolation tools...");
}
if let Some(version) = get_tool_version("screen", "-v", verbose) {
println!(" screen: {}", version);
} else {
println!(" screen: not installed");
}
if let Some(version) = get_tool_version("tmux", "-V", verbose) {
println!(" tmux: {}", version);
} else {
println!(" tmux: not installed");
}
if let Some(version) = get_tool_version("docker", "--version", verbose) {
println!(" docker: {}", version);
} else {
println!(" docker: not installed");
}
}
fn get_tool_version(tool_name: &str, version_flag: &str, verbose: bool) -> Option<String> {
let which_cmd = if cfg!(windows) { "where" } else { "which" };
let exists = Command::new(which_cmd)
.arg(tool_name)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !exists {
if verbose {
println!("[verbose] {}: not found in PATH", tool_name);
}
return None;
}
let output = Command::new(tool_name).arg(version_flag).output().ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{}{}", stdout, stderr).trim().to_string();
if verbose {
println!(
"[verbose] {} {}: exit={}, output=\"{}\"",
tool_name,
version_flag,
output.status.code().unwrap_or(-1),
&combined[..100.min(combined.len())]
);
}
if combined.is_empty() {
return None;
}
combined.lines().next().map(String::from)
}
fn handle_status_query(config: &Config, uuid: &str, output_format: Option<&str>) {
let store = config.create_execution_store();
let result = query_status(store.as_ref(), uuid, output_format);
if result.success {
if let Some(output) = result.output {
println!("{}", output);
}
} else {
if let Some(error) = result.error {
eprintln!("Error: {}", error);
}
process::exit(1);
}
}
fn handle_list_query(config: &Config, output_format: Option<&str>) {
let store = config.create_execution_store();
let result = list_executions(store.as_ref(), output_format);
if result.success {
if let Some(output) = result.output {
println!("{}", output);
}
} else {
if let Some(error) = result.error {
eprintln!("Error: {}", error);
}
process::exit(1);
}
}
fn handle_upload_log_query(config: &Config, identifier: &str) -> i32 {
let store = config.create_execution_store();
match upload_execution_log(store.as_ref(), identifier) {
Ok(code) => code,
Err(error) => {
eprintln!("Error: {}", error);
1
}
}
}
fn handle_control_query(config: &Config, identifier: &str, action: ControlAction) {
let store = config.create_execution_store();
let result = control_execution(store.as_ref(), identifier, action);
if result.success {
if let Some(output) = result.output {
println!("{}", output);
}
} else {
if let Some(error) = result.error {
eprintln!("Error: {}", error);
}
process::exit(1);
}
}
fn handle_cleanup(config: &Config, dry_run: bool) {
let store = match config.create_execution_store() {
Some(s) => s,
None => {
eprintln!("Error: Execution tracking is disabled.");
process::exit(1);
}
};
let result = store.cleanup_stale(CleanupOptions {
dry_run,
..Default::default()
});
for error in &result.errors {
eprintln!("Error: {}", error);
}
if result.records.is_empty() {
println!("No stale records found.");
return;
}
if dry_run {
println!(
"Found {} stale record(s) that would be cleaned up:\n",
result.records.len()
);
} else {
println!("Cleaned up {} stale record(s):\n", result.cleaned);
}
for record in &result.records {
let start_time_display = chrono::DateTime::parse_from_rfc3339(&record.start_time)
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|_| record.start_time.clone());
println!(" UUID: {}", record.uuid);
println!(" Command: {}", record.command);
println!(" Started: {}", start_time_display);
println!(
" PID: {}",
record
.pid
.map(|p| p.to_string())
.unwrap_or("N/A".to_string())
);
println!();
}
if dry_run {
println!("Run with --cleanup to actually clean up these records.");
}
}
fn run_with_isolation(
config: &Config,
wrapper_options: &start_command::WrapperOptions,
command: &str,
_use_command_stream: bool,
session_id: &str,
) {
let environment = wrapper_options.isolated.as_deref();
let mode = get_effective_mode(wrapper_options);
let start_time = get_timestamp();
let start_instant = std::time::Instant::now();
let effective_image = wrapper_options.image.clone();
let log_file_path = create_log_path_for_execution(environment.unwrap_or("direct"), session_id);
let session_name = wrapper_options
.session
.clone()
.unwrap_or_else(|| generate_session_name(Some(environment.unwrap_or("start"))));
let mut extra_lines: Vec<String> = Vec::new();
let mut created_user: Option<String> = None;
if wrapper_options.user {
if !has_sudo_access() {
eprintln!("Error: --isolated-user requires sudo access without password.");
eprintln!("Configure NOPASSWD in sudoers or run with appropriate permissions.");
process::exit(1);
}
let current_groups = get_current_user_groups();
let important_groups: Vec<&str> = ["sudo", "docker", "wheel", "admin"]
.iter()
.copied()
.filter(|g| current_groups.iter().any(|cg| cg == *g))
.collect();
extra_lines.push("[User Isolation] Creating new user...".to_string());
if !important_groups.is_empty() {
extra_lines.push(format!(
"[User Isolation] Inheriting groups: {}",
important_groups.join(", ")
));
}
let user_result = create_isolated_user(
wrapper_options.user_name.as_deref(),
&CreateIsolatedUserOptions::default(),
);
if !user_result.success {
eprintln!(
"Error: Failed to create isolated user: {}",
user_result.message
);
process::exit(1);
}
let username = user_result.username.unwrap();
extra_lines.push(format!("[User Isolation] Created user: {}", username));
if let Some(groups) = &user_result.groups {
if !groups.is_empty() {
extra_lines.push(format!(
"[User Isolation] User groups: {}",
groups.join(", ")
));
}
}
if wrapper_options.keep_user {
extra_lines.push("[User Isolation] User will be kept after completion".to_string());
}
created_user = Some(username);
}
if let Some(env) = environment {
extra_lines.push(format!("[Isolation] Environment: {}, Mode: {}", env, mode));
extra_lines.push(format!("[Isolation] Session: {}", session_name));
}
if let Some(ref image) = effective_image {
extra_lines.push(format!("[Isolation] Image: {}", image));
}
extra_lines.extend(docker_runtime_status_lines(
&wrapper_options.volumes,
&wrapper_options.mounts,
&wrapper_options.env,
wrapper_options.privileged,
));
if let Some(ref endpoint) = wrapper_options.endpoint {
extra_lines.push(format!("[Isolation] Endpoint: {}", endpoint));
}
if let Some(ref user) = created_user {
extra_lines.push(format!("[Isolation] User: {} (isolated)", user));
}
let is_docker_isolation = environment == Some("docker");
let extra_lines_refs: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect();
println!(
"{}",
create_start_block(&StartBlockOptions {
session_id,
timestamp: &start_time,
command,
extra_lines: if extra_lines.is_empty() {
None
} else {
Some(extra_lines_refs)
},
style: None,
width: None,
defer_command: is_docker_isolation,
})
);
if !is_docker_isolation {
println!();
}
let mut log_content = create_log_header(&LogHeaderParams {
command: command.to_string(),
environment: environment.unwrap_or("direct").to_string(),
mode: mode.to_string(),
session_name: session_name.clone(),
image: effective_image.clone(),
user: created_user.clone(),
start_time: start_time.clone(),
});
let execution_store = config.create_execution_store();
let opts_map = build_isolation_options_map(
environment,
mode,
&session_name,
effective_image.as_deref(),
wrapper_options,
created_user.as_deref(),
);
let mut execution_record = ExecutionRecord::with_options(ExecutionRecordOptions {
uuid: Some(session_id.to_string()),
command: command.to_string(),
log_path: Some(log_file_path.to_string_lossy().to_string()),
pid: Some(process::id()),
options: Some(opts_map),
..Default::default()
});
if let Some(ref store) = execution_store {
match store.save(&execution_record) {
Err(e) if config.verbose => {
eprintln!(
"[ExecutionStore] Warning: Failed to save initial record: {}",
e
);
}
Ok(()) => {
if config.verbose {
println!("[ExecutionStore] Execution ID: {}", execution_record.uuid);
}
set_current_execution(execution_record.clone(), store.clone());
}
_ => {}
}
}
log_content = log_content.replacen(
"=== Start Command Log ===\n",
&format!(
"=== Start Command Log ===\nExecution ID: {}\n",
execution_record.uuid
),
1,
);
write_log_file(&log_file_path, &log_content);
let result = if let Some(env) = environment {
let options = IsolationOptions {
session: Some(session_name.clone()),
image: effective_image.clone(),
volumes: wrapper_options.volumes.clone(),
mounts: wrapper_options.mounts.clone(),
env: wrapper_options.env.clone(),
privileged: wrapper_options.privileged,
endpoint: wrapper_options.endpoint.clone(),
detached: mode == "detached",
user: created_user.clone(),
keep_alive: wrapper_options.keep_alive,
auto_remove_docker_container: wrapper_options.auto_remove_docker_container,
always_cleanup_container: wrapper_options.always_cleanup_container,
keep_container: wrapper_options.keep_container,
keep_container_on_fail: wrapper_options.keep_container_on_fail,
shell: wrapper_options.shell.clone(),
log_path: Some(log_file_path.clone()),
};
run_isolated(env, command, &options)
} else if let Some(ref user) = created_user {
run_as_isolated_user(command, user)
} else {
start_command::IsolationResult {
success: false,
message: "No isolation configuration provided".to_string(),
..Default::default()
}
};
let exit_code = result
.exit_code
.unwrap_or(if result.success { 0 } else { 1 });
let end_time = get_timestamp();
if mode == "detached" && result.success {
append_log_file(&log_file_path, &format!("{}\n", result.message));
} else {
append_log_file(&log_file_path, &format!("{}\n", result.message));
append_log_file(&log_file_path, &create_log_footer(&end_time, exit_code));
}
if let Some(ref store) = execution_store {
if let Some(container_id) = result.container_id.clone() {
execution_record.options.insert(
"containerId".to_string(),
serde_json::Value::String(container_id),
);
}
if mode != "detached" {
execution_record.complete(exit_code);
}
if let Err(e) = store.save(&execution_record) {
if config.verbose {
eprintln!("[ExecutionStore] Warning: Failed to update record: {}", e);
}
}
clear_current_execution();
}
if let Some(ref user) = created_user {
if !wrapper_options.keep_user {
println!("[User Isolation] Cleaning up user: {}", user);
let delete_result = delete_user(user, &DeleteUserOptions { remove_home: true });
if delete_result.success {
println!("[User Isolation] User deleted successfully");
} else {
println!("[User Isolation] Warning: {}", delete_result.message);
}
println!();
} else {
println!(
"[User Isolation] Keeping user: {} (use 'sudo userdel -r {}' to delete)",
user, user
);
println!();
}
}
println!();
let duration_ms = start_instant.elapsed().as_secs_f64() * 1000.0;
let extra_lines_refs: Vec<&str> = extra_lines.iter().map(|s| s.as_str()).collect();
println!(
"{}",
create_finish_block(&FinishBlockOptions {
session_id,
timestamp: &end_time,
exit_code,
log_path: &log_file_path.to_string_lossy(),
duration_ms: Some(duration_ms),
result_message: Some(&result.message),
extra_lines: Some(extra_lines_refs),
style: None,
width: None,
})
);
process::exit(exit_code);
}
fn run_direct(
config: &Config,
command: &str,
parsed_command: &str,
substitution_result: Option<&start_command::SubstitutionResult>,
session_id: &str,
) {
let start_time = get_timestamp();
let start_instant = std::time::Instant::now();
let display_command = if let Some(sub) = substitution_result {
if sub.matched {
format!("{} -> {}", parsed_command, command)
} else {
command.to_string()
}
} else {
command.to_string()
};
println!(
"{}",
create_start_block(&StartBlockOptions {
session_id,
timestamp: &start_time,
command: &display_command,
extra_lines: None,
style: None,
width: None,
defer_command: false,
})
);
println!();
let command_name = command.split_whitespace().next().unwrap_or(command);
let is_windows = cfg!(windows);
let shell = if is_windows {
"powershell.exe".to_string()
} else {
env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
};
let shell_args: Vec<&str> = if is_windows {
vec!["-Command", command]
} else {
vec!["-c", command]
};
let log_file_path = create_log_path_for_execution("direct", session_id);
let mut log_content = String::new();
let execution_store = config.create_execution_store();
let mut execution_record = ExecutionRecord::with_options(ExecutionRecordOptions {
uuid: Some(session_id.to_string()),
command: command.to_string(),
log_path: Some(log_file_path.to_string_lossy().to_string()),
pid: Some(process::id()),
..Default::default()
});
if let Some(ref store) = execution_store {
if let Err(e) = store.save(&execution_record) {
if config.verbose {
eprintln!(
"[ExecutionStore] Warning: Failed to save initial record: {}",
e
);
}
} else {
if config.verbose {
println!("[ExecutionStore] Execution ID: {}", execution_record.uuid);
}
set_current_execution(execution_record.clone(), store.clone());
}
}
log_content.push_str("=== Start Command Log ===\n");
log_content.push_str(&format!("Timestamp: {}\n", start_time));
if let Some(sub) = substitution_result {
if sub.matched {
log_content.push_str(&format!("Original Input: {}\n", parsed_command));
log_content.push_str(&format!("Substituted Command: {}\n", command));
if let Some(ref rule) = sub.rule {
log_content.push_str(&format!("Pattern Matched: {}\n", rule.pattern));
}
}
} else {
log_content.push_str(&format!("Command: {}\n", command));
}
log_content.push_str(&format!("Shell: {}\n", shell));
log_content.push_str(&format!("Platform: {}\n", std::env::consts::OS));
log_content.push_str(&format!(
"Working Directory: {}\n",
env::current_dir().unwrap_or_default().display()
));
log_content.push_str(&format!("{}\n\n", "=".repeat(50)));
write_log_file(&log_file_path, &log_content);
let mut child = match Command::new(&shell)
.args(&shell_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
let error_msg = format!("Error executing command: {}", e);
log_content.push_str(&format!("\n{}\n", error_msg));
eprintln!("\n{}", error_msg);
let end_time = get_timestamp();
log_content.push_str(&format!("\n{}\n", "=".repeat(50)));
log_content.push_str(&format!("Finished: {}\n", end_time));
log_content.push_str("Exit Code: 1\n");
write_log_file(&log_file_path, &log_content);
let duration_ms = start_instant.elapsed().as_secs_f64() * 1000.0;
println!();
println!(
"{}",
create_finish_block(&FinishBlockOptions {
session_id,
timestamp: &end_time,
exit_code: 1,
log_path: &log_file_path.to_string_lossy(),
duration_ms: Some(duration_ms),
result_message: None,
extra_lines: None,
style: None,
width: None,
})
);
process::exit(1);
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let stdout_log_path = log_file_path.clone();
let stdout_handle = std::thread::spawn(move || {
let mut output = String::new();
if let Some(stdout) = stdout {
let reader = BufReader::new(stdout);
for line in reader.lines().map_while(Result::ok) {
println!("{}", line);
append_log_file(&stdout_log_path, &format!("{}\n", line));
output.push_str(&line);
output.push('\n');
}
}
output
});
let stderr_log_path = log_file_path.clone();
let stderr_handle = std::thread::spawn(move || {
let mut output = String::new();
if let Some(stderr) = stderr {
let reader = BufReader::new(stderr);
for line in reader.lines().map_while(Result::ok) {
eprintln!("{}", line);
append_log_file(&stderr_log_path, &format!("{}\n", line));
output.push_str(&line);
output.push('\n');
}
}
output
});
let stdout_output = stdout_handle.join().unwrap_or_default();
let stderr_output = stderr_handle.join().unwrap_or_default();
let exit_code = match child.wait() {
Ok(status) => status.code().unwrap_or(1),
Err(e) => {
let error_msg = format!("Error waiting for command: {}", e);
log_content.push_str(&format!("\n{}\n", error_msg));
append_log_file(&log_file_path, &format!("\n{}\n", error_msg));
eprintln!("\n{}", error_msg);
1
}
};
if !stdout_output.is_empty() {
log_content.push_str(&stdout_output);
}
if !stderr_output.is_empty() {
log_content.push_str(&stderr_output);
}
let end_time = get_timestamp();
log_content.push_str(&format!("\n{}\n", "=".repeat(50)));
log_content.push_str(&format!("Finished: {}\n", end_time));
log_content.push_str(&format!("Exit Code: {}\n", exit_code));
append_log_file(
&log_file_path,
&format!(
"\n{}\nFinished: {}\nExit Code: {}\n",
"=".repeat(50),
end_time,
exit_code
),
);
let duration_ms = start_instant.elapsed().as_secs_f64() * 1000.0;
println!();
println!(
"{}",
create_finish_block(&FinishBlockOptions {
session_id,
timestamp: &end_time,
exit_code,
log_path: &log_file_path.to_string_lossy(),
duration_ms: Some(duration_ms),
result_message: None,
extra_lines: None,
style: None,
width: None,
})
);
if let Some(ref store) = execution_store {
execution_record.complete(exit_code);
if let Err(e) = store.save(&execution_record) {
if config.verbose {
eprintln!(
"[ExecutionStore] Warning: Failed to save completion record: {}",
e
);
}
}
clear_current_execution();
}
if exit_code != 0 {
handle_failure(
&FailureConfig {
disable_auto_issue: config.disable_auto_issue,
disable_log_upload: config.disable_log_upload,
verbose: config.verbose,
},
command_name,
command,
exit_code,
&log_file_path.to_string_lossy(),
);
}
process::exit(exit_code);
}