Something went wrong. Try again.
Nix-based distributed command runner now.dev.br
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372// 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 <https://www.gnu.org/licenses/>.
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<PathBuf>, flake: Option<String>,) -> color_eyre::Result<WorkflowSource> { 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::<color_eyre::Result<()>>(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(())}