// 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::{ num::NonZeroUsize, path::{Path, PathBuf}, }; use ahash::HashSet; use clap::{CommandFactory, Parser, ValueEnum}; use clap_complete::{ArgValueCandidates, ArgValueCompleter, CompletionCandidate, PathCompleter}; use color_eyre::eyre::OptionExt; use crate::{environment::NowEnvironment, find_workflow}; #[derive(Debug, Clone, Copy, Default, ValueEnum)] pub(crate) enum TracingMode { /// (default) Don't include ANSI colors in output. #[default] NoColors, /// Include ANSI colors in output. Colors, } #[derive(Debug, Clone, Copy, Default, ValueEnum)] pub(crate) enum LoggingLevel { /// Don't print anything before logs. Minimal, /// Only print host before logs. Host, /// Only print step before logs. Step, /// (default) Print host and step before logs. #[default] Full, } #[derive(Parser)] pub(crate) enum Command { /// Initialize a basic workflow. Init { /// Path to the workflow. workflow: Option, }, /// List the jobs available in the workflow. List { /// Path to the workflow. /// /// Cannot be used together with the `--flake` option. #[arg( short, long, value_name = "FILE", add = ArgValueCompleter::new(PathCompleter::any().filter(workflow_filter)), )] workflow: Option, /// Path to the flake and an optional attribute (defaults to the `now` output). /// /// Cannot be used together with the `--workflow` option. #[arg(short, long, value_name = "FLAKE[#ATTR]", conflicts_with = "workflow")] flake: Option, /// Optional dotenv file to read environment variables from. #[arg(short, long, value_name = "FILE")] env_file: Option, /// In which directory to evaluate the workflow. #[arg( short, long, add = ArgValueCompleter::new(PathCompleter::dir()), )] cwdir: Option, /// Whether to print jobs as a graphviz-compatible .dot tree graph. #[arg(short, long)] tree: bool, }, /// Evaluate a workflow, printing its JSON representation. Eval { /// Path to the workflow. /// /// Cannot be used together with the `--flake` option. #[arg( short, long, value_name = "FILE", add = ArgValueCompleter::new(PathCompleter::any().filter(workflow_filter)), )] workflow: Option, /// Path to the flake and an optional attribute (defaults to the `now` output). /// /// Cannot be used together with the `--workflow` option. #[arg(short, long, value_name = "FLAKE[#ATTR]", conflicts_with = "workflow")] flake: Option, /// Optional dotenv file to read environment variables from. #[arg(short, long, value_name = "FILE")] env_file: Option, /// In which directory to evaluate the workflow. #[arg( short, long, add = ArgValueCompleter::new(PathCompleter::dir()), )] cwdir: Option, }, /// Run one or more jobs. Run { /// Jobs to target in this run. Unix-style globs are supported. /// /// If unspecified, the default jobs of the workflow are run. /// /// Cannot be used together with the `--all-jobs` option. #[arg( value_name = "JOB", add = ArgValueCandidates::new(job_completer) )] jobs: Option>, /// Path to the workflow. /// /// Cannot be used together with the `--flake` option. #[arg( short, long, value_name = "FILE", add = ArgValueCompleter::new(PathCompleter::any().filter(workflow_filter)), )] workflow: Option, /// Path to the flake and an optional attribute (defaults to the `now` output). /// /// Cannot be used together with the `--workflow` option. #[arg(short, long, value_name = "FLAKE[#ATTR]", conflicts_with = "workflow")] flake: Option, /// Don't realize derivations or run jobs. /// /// Useful for debugging workflows before running them. #[arg(long)] dry_run: bool, /// Run all jobs in the workflow. /// /// Cannot be used together with any `[JOB]` arguments. #[arg(long, conflicts_with = "jobs")] all_jobs: bool, /// Optional dotenv file to read environment variables from. #[arg(short, long, value_name = "FILE")] env_file: Option, /// Directory where Nix GC roots for realized steps are kept. /// /// Defaults to a temporary directory removed when `now` exits. #[arg( long, value_name = "DIR", add = ArgValueCompleter::new(PathCompleter::dir()), )] gcroot_dir: Option, /// Immediately abort on the first job failure. #[arg(long)] abort: bool, /// Graceful termination timeout for steps when the job is aborted. /// /// When enabled, SIGTERM is first sent to each currently running step, /// then after the grace period, SIGKILL is sent to each still running step. /// /// A duration less than one second will signal instant termination. #[arg(long, value_name = "DURATION", default_value = "10s")] termination_timeout: humantime::Duration, /// Timeout for the entire workflow, eg. `1h`. #[arg(long, value_name = "DURATION")] timeout: Option, /// How often to send keep-alive messages to remote hosts. /// After not receiving a response 3 times, the job fails. /// /// Only set if the duration is greater than or equal to `1s`. /// /// Cannot be used together with the `--local-only` option. #[arg(long)] keep_alive: Option, /// In which directory to run the workflow. /// /// Defaults to the current directory if --workflow is set, /// and the directory that `now.nix` is in otherwise #[arg( short, long, add = ArgValueCompleter::new(PathCompleter::dir()), )] cwdir: Option, /// A semicolon-separated list of build machines. /// When specified, overrides the remote builders configuration of the host. /// /// Cannot be used together with the `--local-only` option. /// /// For more information on the syntax, see: /// #[arg(long)] builders: Option, /// How many simultaneous jobs to use for local builds. /// /// Defaults to the number of physical cores in the current machine. /// /// Cannot be used together with the `--remote-only` option. #[arg(long, conflicts_with = "remote_only")] cores: Option, /// When specified, ignores the remote builders configuration of the host, /// running all jobs in the local builder. /// /// Jobs that cannot run in the local builder will fail. /// /// Cannot be used together with either the `--builders`, `--remote-only`, /// or `--keep-alive` options. #[arg(long, conflicts_with_all = ["builders", "remote_only", "keep_alive"])] local_only: bool, /// When specified, runs all jobs in remote builders, /// only using the local runner for job orchestration. /// /// Cannot be used together with either the `--cores` or `--local-only` options. #[arg(long)] remote_only: bool, /// When specified, skips jobs that don't match any builders or runners /// and their dependencies, instead of failing. #[arg(long)] skip: bool, /// Controls what extra info is prefixed on each log line. /// /// Cannot be used together with the `--tracing` option. #[arg(short, long, default_value = "full")] logging: LoggingLevel, /// Whether to emit traces in Duper instead of colored logs. /// /// You can also set whether ANSI colors are included in the traces or not. /// /// For more information on Duper: /// /// Cannot be used together with the `--logging` option. #[arg( long, value_enum, num_args = 0..=1, default_missing_value = "no-colors", conflicts_with = "logging", )] tracing: Option, }, } fn workflow_filter(path: &Path) -> bool { path.is_dir() || path.extension().is_some_and(|extension| extension == "nix") } fn job_completer() -> Vec { let result: color_eyre::Result<_> = (|| { let command_matches = match Command::command().try_get_matches_from(std::env::args_os()) { Ok(command_matches) => command_matches, Err(_) => Command::command().try_get_matches_from(std::env::args_os().skip(2))?, }; let matches = command_matches .subcommand_matches("run") .ok_or_eyre("Not run subcommand")?; let maybe_workflow = matches.try_get_one::("workflow")?; let maybe_flake = matches.try_get_one::("flake")?; let workflow = find_workflow(maybe_workflow.cloned(), maybe_flake.cloned())?; let jobs_iter = matches.try_get_many::("jobs")?.unwrap_or_default(); 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) .build(), )?; let mut jobs_iter = jobs_iter.rev(); let current_job = jobs_iter.next(); let jobs: HashSet<&String> = jobs_iter.collect(); Ok(environment .jobs .into_iter() .filter_map(|(job, help)| { if current_job.is_none_or(|current_job| job.starts_with(current_job)) && !jobs.contains(&job) { Some(CompletionCandidate::new(job).help(Some(help.into()))) } else { None } }) .collect()) })(); result.unwrap_or_default() }