// now: Nix-based distributed command runner // Copyright (C) 2026 Eric Rodrigues Pires // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU Affero General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS // FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for // more details. // // You should have received a copy of the GNU Affero General Public License along // with this program. If not, see . use std::{ convert::Into, os::unix::fs::PermissionsExt, path::{Path, PathBuf}, }; use color_eyre::eyre::{OptionExt, eyre}; use tracing::{debug, level_filters::LevelFilter}; use tracing_duper::DuperLayer; use tracing_error::ErrorLayer; use tracing_subscriber::{EnvFilter, Layer, layer::SubscriberExt, util::SubscriberInitExt}; use crate::{ command::Command, environment::NowEnvironment, eyre::log_report, subscriber::NowSubscriberLayer, workflow::{NowJobsToRun, NowRunMode, NowWorkflowParams, WorkflowSource}, }; mod builder; mod command; mod environment; mod eyre; mod graph; mod job; mod project; mod secret; mod serde; mod setup; mod subscriber; mod utils; mod workflow; fn find_workflow( workflow: Option, flake: Option, ) -> color_eyre::Result { if let Some(workflow) = workflow { if workflow.is_dir() { let now_path = workflow.join("now.nix"); if now_path.exists() && !now_path.is_dir() { Ok(WorkflowSource::Path(now_path.canonicalize()?)) } else { Err(eyre!( "Workflow 'now.nix' not found in directory '{}'", workflow.canonicalize()?.to_string_lossy() )) } } else if !workflow.exists() { Err(eyre!("Workflow '{}' not found", workflow.to_string_lossy())) } else { Ok(WorkflowSource::Path(workflow.canonicalize()?)) } } else if let Some(flake) = flake { let (path, attribute) = flake.trim().split_once('#').unwrap_or((&flake, "now")); // Validate that each segment is between double-quotes, isn't empty, or doesn't have invalid characters let mut quoted = false; let mut attribute_chars = attribute.chars().enumerate().peekable(); while let Some((i, char)) = attribute_chars.next() { match char { '"' => { if quoted { if attribute_chars.peek().is_some_and(|(_, next)| *next != '.') { return Err(eyre!("Invalid quoted attribute")); } quoted = false; } else if i > 0 { return Err(eyre!("Invalid quoted attribute")); } else { quoted = true; } } '.' => { if !quoted { let Some((_, next)) = attribute_chars.peek() else { return Err(eyre!("Empty attribute name")); }; if *next == '.' { return Err(eyre!("Empty attribute name")); } if *next == '"' { quoted = true; let _ = attribute_chars.next(); } } } '\\' if quoted => match attribute_chars.next() { Some((_, '\\' | '"' | '$' | 'n' | 'r' | 't')) => {} Some((_, c)) => { return Err(eyre!("Invalid escape sequence: \\{}", c)); } None => return Err(eyre!("Invalid escape")), }, _ if quoted => {} 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => {} _ => return Err(eyre!("Invalid character")), } } if quoted { return Err(eyre!("Unclosed quoted attribute")); } // If path is a filesystem path, convert to an absolute path let path = if path.contains(':') { path.to_string() } else { let path = Path::new(path).canonicalize()?; std::env::set_current_dir(if path.is_dir() { &path } else { path.parent().expect("flake has parent directory") })?; path.to_str() .ok_or_eyre("Flake path is not UTF-8")? .to_string() }; Ok(WorkflowSource::Flake { path, attribute: attribute.to_string(), }) } else { let canonical_cwdir = std::env::current_dir()?.canonicalize()?; let mut cwdir = Some(canonical_cwdir.as_path()); while let Some(cwdir_path) = cwdir { let now_path = cwdir_path.join("now.nix"); if now_path.exists() && !now_path.is_dir() { std::env::set_current_dir(cwdir_path)?; return Ok(WorkflowSource::Path(now_path)); } cwdir = cwdir_path.parent(); } Err(eyre!( "No workflow found recursively from '{}'", canonical_cwdir.to_string_lossy() )) } } fn main() -> color_eyre::Result<()> { let command = setup::setup()?; match command { Command::Init { workflow } => { let mut path = workflow.unwrap_or(PathBuf::from(".")); if path.is_dir() || (!path.exists() && path.extension().is_none_or(|extension| extension != "nix")) { path.push("now.nix"); } if path.exists() { return Err(eyre!("'{}' already exists", path.to_string_lossy(),)); } if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(&path, include_bytes!("init.nix"))?; let mut perms = std::fs::metadata(&path)?.permissions(); perms.set_mode(0o755); std::fs::set_permissions(&path, perms)?; println!( "'{}' has been initialized with a basic workflow", path.to_string_lossy(), ); } Command::List { workflow, flake, env_file, cwdir, tree, } => { let workflow = find_workflow(workflow, flake)?; if let Some(cwdir) = cwdir { std::env::set_current_dir(cwdir)?; } let (sender, ctrl_c) = smol::channel::bounded(1); let _ = ctrlc::set_handler(move || { let _ = sender.try_send(()); }); let environment = smol::block_on( NowEnvironment::builder() .workflow(&workflow) .ctrl_c(ctrl_c) .maybe_env_file(env_file) .build(), )?; if tree { let workflow = smol::block_on(environment.evaluate_workflow(&workflow))?; let graph = workflow.into_graph()?; println!("{}", graph.to_dot()); } else if let Some((width, _)) = terminal_size::terminal_size_of(std::io::stdout()) { print!( "{}", term_grid::Grid::new( environment.jobs.keys().collect(), term_grid::GridOptions { direction: term_grid::Direction::TopToBottom, filling: term_grid::Filling::Spaces(3), width: usize::from(width.0), }, ) ); } else { for job in environment.jobs.keys() { println!("{job}"); } } } Command::Eval { workflow, flake, env_file, cwdir, } => { let workflow = find_workflow(workflow, flake)?; if let Some(cwdir) = cwdir { std::env::set_current_dir(cwdir)?; } let (sender, ctrl_c) = smol::channel::bounded(1); ctrlc::set_handler(move || { let _ = sender.try_send(()); })?; smol::block_on(async { let environment = NowEnvironment::builder() .workflow(&workflow) .ctrl_c(ctrl_c) .maybe_env_file(env_file) .build() .await?; let evaluated = environment.evaluate_workflow(&workflow).await?; println!("{}", serde_json::to_string(&evaluated)?); Ok::<(), color_eyre::Report>(()) })?; } Command::Run { jobs, workflow, flake, dry_run, all_jobs, env_file, gcroot_dir, abort, termination_timeout, timeout, keep_alive, cwdir, builders, cores, local_only, remote_only, skip, logging, tracing, } => { let env_filter = EnvFilter::builder() .with_default_directive(LevelFilter::INFO.into()) .from_env_lossy(); if tracing.is_some() { tracing_subscriber::registry() .with( DuperLayer::default() .with_span_timings(true) .with_filter(env_filter), ) .with(ErrorLayer::default()) .init(); } else { tracing_subscriber::registry() .with( NowSubscriberLayer::default() .with_level(logging) .with_filter(env_filter), ) .with(ErrorLayer::default()) .init(); } let workflow = find_workflow(workflow, flake)?; if let Some(cwdir) = cwdir { debug!("Changing cwdir to '{}'...", cwdir.to_string_lossy()); std::env::set_current_dir(cwdir)?; } let (sender, ctrl_c) = smol::channel::bounded(1); ctrlc::set_handler(move || { let _ = sender.try_send(()); })?; let run_mode = if local_only { NowRunMode::LocalOnly } else if remote_only { NowRunMode::RemoteOnly } else { NowRunMode::All }; let jobs = if let Some(jobs) = jobs { NowJobsToRun::Selected(jobs) } else if all_jobs { NowJobsToRun::All } else { NowJobsToRun::Default }; let result = smol::block_on::>(async { let mut environment = NowEnvironment::builder() .workflow(&workflow) .ctrl_c(ctrl_c.clone()) .maybe_tracing(tracing) .maybe_env_file(env_file) .maybe_gcroot_dir(gcroot_dir) .termination_timeout(termination_timeout.into()) .build() .await?; environment .run_workflow(NowWorkflowParams { workflow, ctrl_c, dry_run, abort, timeout: timeout.map(Into::into), keep_alive: keep_alive.map(Into::into), jobs_to_run: jobs, builders, cores, run_mode, skip, }) .await }); if let Err(error) = result { if tracing.is_some() { log_report(&error); } return Err(error); } } } Ok(()) }