Skip to content

Commit e10d245

Browse files
authored
feat(nodeup): add human output color controls via --color and NODEUP_COLOR (#308)
## Summary - add global `--color <auto|always|never>` to `nodeup` management CLI - add `NODEUP_COLOR` support with precedence `--color` > `NODEUP_COLOR` > `NO_COLOR` > stream-aware `auto` - introduce `output_style` module and wire styled human output through command output + handled stderr errors - keep JSON and completions output raw (no ANSI injection) ## Documentation - update `docs/crates-nodeup-foundation.md` and `docs/project-nodeup.md` with color contract invariants - update `crates/nodeup/README.md` and `apps/public-docs/nodeup.mdx` for user-facing color controls ## Tests - `cargo test -p nodeup` - `cargo test` - `cd apps/public-docs && pnpm test`
1 parent ec0b348 commit e10d245

18 files changed

Lines changed: 701 additions & 83 deletions

apps/public-docs/nodeup.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,14 @@ nodeup override set lts --path /path/to/project
7272
- Handled failures use an actionable message shape: `<cause>. Hint: <next action>`.
7373
- `completions` always writes raw completion scripts to stdout, even when `--output json` is set.
7474

75+
Human output color control:
76+
77+
- `--color auto|always|never` controls ANSI styling for `--output human` output.
78+
- `NODEUP_COLOR=auto|always|never` controls the same behavior via environment.
79+
- Precedence is `--color` > `NODEUP_COLOR` > `NO_COLOR` > `auto`.
80+
- `auto` enables color only when each output stream is attached to a terminal.
81+
- `--output json` and `completions` output are always emitted without ANSI styling.
82+
7583
## Shell completions
7684

7785
Supported shells:

crates/nodeup/README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,16 @@ If no selector resolves, commands fail with deterministic `not-found` errors.
5656
- always writes raw completion script text to stdout
5757
- does not wrap completion output in JSON, even when `--output json` is set
5858

59-
Color control:
59+
Human output color control:
60+
61+
- Global color mode for human output: `--color auto|always|never` (default: `auto`)
62+
- Environment override for human output: `NODEUP_COLOR=auto|always|never`
63+
- Precedence: `--color` > `NODEUP_COLOR` > `NO_COLOR` > `auto`
64+
- `auto` enables ANSI styles per stream only when the stream is a terminal
65+
- `--output json` never injects ANSI styles into JSON payloads
66+
- `completions` output remains raw shell script text even when `--color always` is set
67+
68+
Log color control:
6069

6170
- `NODEUP_LOG_COLOR=always|auto|never` (default `always`)
6271
- `NO_COLOR` disables color when `NODEUP_LOG_COLOR` is unset or `auto`

crates/nodeup/src/cli.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,23 @@ pub enum OutputFormat {
66
Json,
77
}
88

9+
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
10+
pub enum OutputColorMode {
11+
Auto,
12+
Always,
13+
Never,
14+
}
15+
16+
impl OutputColorMode {
17+
pub fn as_str(self) -> &'static str {
18+
match self {
19+
Self::Auto => "auto",
20+
Self::Always => "always",
21+
Self::Never => "never",
22+
}
23+
}
24+
}
25+
926
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1027
pub enum ToolchainListDetail {
1128
Standard,
@@ -44,6 +61,10 @@ pub struct Cli {
4461
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Human)]
4562
pub output: OutputFormat,
4663

64+
/// Color mode for human output (`auto`, `always`, or `never`).
65+
#[arg(long, global = true, value_enum)]
66+
pub color: Option<OutputColorMode>,
67+
4768
#[command(subcommand)]
4869
pub command: Command,
4970
}

crates/nodeup/src/commands/default_cmd.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use serde::Serialize;
22
use tracing::{info, warn};
33

44
use crate::{
5-
cli::OutputFormat,
5+
cli::{OutputColorMode, OutputFormat},
66
commands::print_output,
77
errors::{ErrorKind, NodeupError, Result},
88
resolver::ResolvedRuntimeTarget,
@@ -32,7 +32,12 @@ impl From<NodeupError> for DefaultResolutionError {
3232
}
3333
}
3434

35-
pub fn execute(runtime: Option<&str>, output: OutputFormat, app: &NodeupApp) -> Result<i32> {
35+
pub fn execute(
36+
runtime: Option<&str>,
37+
output: OutputFormat,
38+
color: Option<OutputColorMode>,
39+
app: &NodeupApp,
40+
) -> Result<i32> {
3641
if let Some(runtime_selector) = runtime {
3742
let resolved = app
3843
.resolver
@@ -63,7 +68,7 @@ pub fn execute(runtime: Option<&str>, output: OutputFormat, app: &NodeupApp) ->
6368
"Default runtime set to {}",
6469
response.default_selector.as_deref().unwrap_or("")
6570
);
66-
print_output(output, &human, &response)?;
71+
print_output(output, color, &human, &response)?;
6772
return Ok(0);
6873
}
6974

@@ -107,7 +112,7 @@ pub fn execute(runtime: Option<&str>, output: OutputFormat, app: &NodeupApp) ->
107112
"Default runtime is not set".to_string()
108113
};
109114

110-
print_output(output, &human, &response)?;
115+
print_output(output, color, &human, &response)?;
111116

112117
Ok(0)
113118
}

crates/nodeup/src/commands/mod.rs

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ use tracing::info;
1414

1515
use crate::{
1616
cli::{
17-
Cli, Command, OutputFormat, OverrideCommand, SelfCommand, ShowCommand, ToolchainCommand,
18-
ToolchainListDetail,
17+
Cli, Command, OutputColorMode, OutputFormat, OverrideCommand, SelfCommand, ShowCommand,
18+
ToolchainCommand, ToolchainListDetail,
1919
},
2020
errors::Result,
21+
output_style,
2122
types::{
2223
NodeupCommand, NodeupOverrideCommand, NodeupSelfCommand, NodeupShowCommand,
2324
NodeupToolchainCommand,
@@ -29,21 +30,23 @@ pub fn execute(cli: Cli, app: &NodeupApp) -> Result<i32> {
2930
log_command_invocation(&cli.command, cli.output);
3031

3132
match cli.command {
32-
Command::Toolchain { command } => toolchain::execute(command, cli.output, app),
33-
Command::Default { runtime } => default_cmd::execute(runtime.as_deref(), cli.output, app),
34-
Command::Show { command } => show::execute(command, cli.output, app),
35-
Command::Update { runtimes } => update_check::update(runtimes, cli.output, app),
36-
Command::Check => update_check::check(cli.output, app),
37-
Command::Override { command } => override_cmd::execute(command, cli.output, app),
33+
Command::Toolchain { command } => toolchain::execute(command, cli.output, cli.color, app),
34+
Command::Default { runtime } => {
35+
default_cmd::execute(runtime.as_deref(), cli.output, cli.color, app)
36+
}
37+
Command::Show { command } => show::execute(command, cli.output, cli.color, app),
38+
Command::Update { runtimes } => update_check::update(runtimes, cli.output, cli.color, app),
39+
Command::Check => update_check::check(cli.output, cli.color, app),
40+
Command::Override { command } => override_cmd::execute(command, cli.output, cli.color, app),
3841
Command::Which { runtime, command } => {
39-
which_cmd::execute(runtime.as_deref(), &command, cli.output, app)
42+
which_cmd::execute(runtime.as_deref(), &command, cli.output, cli.color, app)
4043
}
4144
Command::Run {
4245
install,
4346
runtime,
4447
command,
45-
} => run_cmd::execute(install, &runtime, &command, cli.output, app),
46-
Command::SelfCmd { command } => self_cmd::execute(command, cli.output, app),
48+
} => run_cmd::execute(install, &runtime, &command, cli.output, cli.color, app),
49+
Command::SelfCmd { command } => self_cmd::execute(command, cli.output, cli.color, app),
4750
Command::Completions { shell, command } => {
4851
skeleton::completions(&shell, command.as_deref())
4952
}
@@ -52,11 +55,12 @@ pub fn execute(cli: Cli, app: &NodeupApp) -> Result<i32> {
5255

5356
pub fn print_output<T: Serialize>(
5457
output: OutputFormat,
58+
color: Option<OutputColorMode>,
5559
human_line: &str,
5660
json_value: &T,
5761
) -> Result<()> {
5862
match output {
59-
OutputFormat::Human => println!("{human_line}"),
63+
OutputFormat::Human => println!("{}", output_style::style_human_stdout(human_line, color)),
6064
OutputFormat::Json => println!("{}", serde_json::to_string_pretty(json_value)?),
6165
}
6266

crates/nodeup/src/commands/override_cmd.rs

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use serde::Serialize;
44
use tracing::info;
55

66
use crate::{
7-
cli::{OutputFormat, OverrideCommand},
7+
cli::{OutputColorMode, OutputFormat, OverrideCommand},
88
commands::print_output,
99
errors::Result,
1010
selectors::RuntimeSelector,
@@ -17,17 +17,24 @@ struct OverrideListItem {
1717
selector: String,
1818
}
1919

20-
pub fn execute(command: OverrideCommand, output: OutputFormat, app: &NodeupApp) -> Result<i32> {
20+
pub fn execute(
21+
command: OverrideCommand,
22+
output: OutputFormat,
23+
color: Option<OutputColorMode>,
24+
app: &NodeupApp,
25+
) -> Result<i32> {
2126
match command {
22-
OverrideCommand::List => list(output, app),
23-
OverrideCommand::Set { runtime, path } => set(&runtime, path.as_deref(), output, app),
27+
OverrideCommand::List => list(output, color, app),
28+
OverrideCommand::Set { runtime, path } => {
29+
set(&runtime, path.as_deref(), output, color, app)
30+
}
2431
OverrideCommand::Unset { path, nonexistent } => {
25-
unset(path.as_deref(), nonexistent, output, app)
32+
unset(path.as_deref(), nonexistent, output, color, app)
2633
}
2734
}
2835
}
2936

30-
fn list(output: OutputFormat, app: &NodeupApp) -> Result<i32> {
37+
fn list(output: OutputFormat, color: Option<OutputColorMode>, app: &NodeupApp) -> Result<i32> {
3138
let entries = app
3239
.overrides
3340
.list()?
@@ -39,11 +46,17 @@ fn list(output: OutputFormat, app: &NodeupApp) -> Result<i32> {
3946
.collect::<Vec<_>>();
4047

4148
let human = format!("Configured overrides: {}", entries.len());
42-
print_output(output, &human, &entries)?;
49+
print_output(output, color, &human, &entries)?;
4350
Ok(0)
4451
}
4552

46-
fn set(runtime: &str, path: Option<&str>, output: OutputFormat, app: &NodeupApp) -> Result<i32> {
53+
fn set(
54+
runtime: &str,
55+
path: Option<&str>,
56+
output: OutputFormat,
57+
color: Option<OutputColorMode>,
58+
app: &NodeupApp,
59+
) -> Result<i32> {
4760
let target_path = match path {
4861
Some(path) => PathBuf::from(path),
4962
None => std::env::current_dir()?,
@@ -74,19 +87,20 @@ fn set(runtime: &str, path: Option<&str>, output: OutputFormat, app: &NodeupApp)
7487
"status": "set"
7588
});
7689

77-
print_output(output, &human, &response)?;
90+
print_output(output, color, &human, &response)?;
7891
Ok(0)
7992
}
8093

8194
fn unset(
8295
path: Option<&str>,
8396
nonexistent: bool,
8497
output: OutputFormat,
98+
color: Option<OutputColorMode>,
8599
app: &NodeupApp,
86100
) -> Result<i32> {
87101
let path = path.map(PathBuf::from);
88102
let removed = app.overrides.unset(path.as_deref(), nonexistent)?;
89103
let human = format!("Removed {} override(s)", removed.len());
90-
print_output(output, &human, &removed)?;
104+
print_output(output, color, &human, &removed)?;
91105
Ok(0)
92106
}

crates/nodeup/src/commands/run_cmd.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use serde::Serialize;
44
use tracing::info;
55

66
use crate::{
7-
cli::OutputFormat,
7+
cli::{OutputColorMode, OutputFormat},
88
commands::print_output,
99
errors::{NodeupError, Result},
1010
process::{run_command, DelegatedStdioPolicy},
@@ -25,6 +25,7 @@ pub fn execute(
2525
runtime: &str,
2626
command: &[String],
2727
output: OutputFormat,
28+
color: Option<OutputColorMode>,
2829
app: &NodeupApp,
2930
) -> Result<i32> {
3031
if command.is_empty() {
@@ -111,6 +112,6 @@ pub fn execute(
111112
delegated_command, exit_code
112113
);
113114

114-
print_output(output, &human, &response)?;
115+
print_output(output, color, &human, &response)?;
115116
Ok(exit_code)
116117
}

crates/nodeup/src/commands/self_cmd.rs

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use toml::{value::Table, Value};
1111
use tracing::{info, warn};
1212

1313
use crate::{
14-
cli::{OutputFormat, SelfCommand},
14+
cli::{OutputColorMode, OutputFormat, SelfCommand},
1515
commands::print_output,
1616
errors::{NodeupError, Result},
1717
overrides::{OverrideEntry, OverridesFile, OVERRIDES_SCHEMA_VERSION},
@@ -171,15 +171,20 @@ struct SelfUpgradeDataResponse {
171171
overrides: SchemaMigrationResult,
172172
}
173173

174-
pub fn execute(command: SelfCommand, output: OutputFormat, app: &NodeupApp) -> Result<i32> {
174+
pub fn execute(
175+
command: SelfCommand,
176+
output: OutputFormat,
177+
color: Option<OutputColorMode>,
178+
app: &NodeupApp,
179+
) -> Result<i32> {
175180
match command {
176-
SelfCommand::Update => update(output, app),
177-
SelfCommand::Uninstall => uninstall(output, app),
178-
SelfCommand::UpgradeData => upgrade_data(output, app),
181+
SelfCommand::Update => update(output, color, app),
182+
SelfCommand::Uninstall => uninstall(output, color, app),
183+
SelfCommand::UpgradeData => upgrade_data(output, color, app),
179184
}
180185
}
181186

182-
fn update(output: OutputFormat, _app: &NodeupApp) -> Result<i32> {
187+
fn update(output: OutputFormat, color: Option<OutputColorMode>, _app: &NodeupApp) -> Result<i32> {
183188
let action = SelfAction::Update;
184189
let command_path = action.command_path();
185190

@@ -223,12 +228,12 @@ fn update(output: OutputFormat, _app: &NodeupApp) -> Result<i32> {
223228
status.as_str(),
224229
response.target_binary
225230
);
226-
print_output(output, &human, &response)?;
231+
print_output(output, color, &human, &response)?;
227232

228233
Ok(0)
229234
}
230235

231-
fn uninstall(output: OutputFormat, app: &NodeupApp) -> Result<i32> {
236+
fn uninstall(output: OutputFormat, color: Option<OutputColorMode>, app: &NodeupApp) -> Result<i32> {
232237
let action = SelfAction::Uninstall;
233238

234239
let mut deletion_targets = Vec::new();
@@ -305,12 +310,16 @@ fn uninstall(output: OutputFormat, app: &NodeupApp) -> Result<i32> {
305310
status.as_str(),
306311
response.removed_paths.len()
307312
);
308-
print_output(output, &human, &response)?;
313+
print_output(output, color, &human, &response)?;
309314

310315
Ok(0)
311316
}
312317

313-
fn upgrade_data(output: OutputFormat, app: &NodeupApp) -> Result<i32> {
318+
fn upgrade_data(
319+
output: OutputFormat,
320+
color: Option<OutputColorMode>,
321+
app: &NodeupApp,
322+
) -> Result<i32> {
314323
let action = SelfAction::UpgradeData;
315324
let settings = migrate_settings_schema(app).map_err(|error| log_failure(action, error))?;
316325
let overrides = migrate_overrides_schema(app).map_err(|error| log_failure(action, error))?;
@@ -343,7 +352,7 @@ fn upgrade_data(output: OutputFormat, app: &NodeupApp) -> Result<i32> {
343352
response.settings.status.as_str(),
344353
response.overrides.status.as_str()
345354
);
346-
print_output(output, &human, &response)?;
355+
print_output(output, color, &human, &response)?;
347356

348357
Ok(0)
349358
}

0 commit comments

Comments
 (0)