// 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()
}