1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
use std::io::Read as _;
use std::path::{Path, PathBuf};
use super::args::EnvVarArg;
use crate::agecrypt;
use crate::config::config_file::ConfigFile;
use crate::config::config_file::mise_toml::MiseToml;
use crate::config::env_directive::EnvDirective;
use crate::config::{Config, ConfigPathOptions, Settings, resolve_target_config_path};
use crate::env::{self};
use crate::file::display_path;
use crate::ui::table;
use demand::Input;
use eyre::{Result, bail, eyre};
use tabled::Tabled;
/// Set environment variables in mise.toml
///
/// By default, this command modifies `mise.toml` in the current directory.
/// If multiple config files exist (e.g., both `mise.toml` and `mise.local.toml`),
/// the lowest precedence file (`mise.toml`) will be used.
/// See https://mise.jdx.dev/configuration.html#target-file-for-write-operations
///
/// Use `-E <env>` to create/modify environment-specific config files like `mise.<env>.toml`.
#[derive(Debug, clap::Args)]
#[clap(aliases = ["ev", "env-vars"], verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
pub struct Set {
/// Environment variable(s) to set
/// e.g.: NODE_ENV=production
#[clap(value_name = "ENV_VAR", verbatim_doc_comment)]
env_vars: Option<Vec<EnvVarArg>>,
/// Create/modify an environment-specific config file like .mise.<env>.toml
#[clap(short = 'E', long, overrides_with_all = &["global", "file"])]
env: Option<String>,
/// Set the environment variable in the global config file
#[clap(short, long, verbatim_doc_comment, overrides_with_all = &["file", "env"])]
global: bool,
/// [experimental] Encrypt the value with age before storing
#[clap(long, requires = "env_vars")]
age_encrypt: bool,
/// [experimental] Age identity file for encryption
///
/// Defaults to ~/.config/mise/age.txt if it exists
#[clap(long, value_name = "PATH", requires = "age_encrypt", value_hint = clap::ValueHint::FilePath)]
age_key_file: Option<PathBuf>,
/// [experimental] Age recipient (x25519 public key) for encryption
///
/// Can be used multiple times. Requires --age-encrypt.
#[clap(long, value_name = "RECIPIENT", requires = "age_encrypt")]
age_recipient: Vec<String>,
/// [experimental] SSH recipient (public key or path) for age encryption
///
/// Can be used multiple times. Requires --age-encrypt.
#[clap(long, value_name = "PATH_OR_PUBKEY", requires = "age_encrypt")]
age_ssh_recipient: Vec<String>,
/// Render completions
#[clap(long, hide = true)]
complete: bool,
/// The TOML file to update
///
/// Can be a file path or directory. If a directory is provided, will create/use mise.toml in that directory.
/// Defaults to MISE_DEFAULT_CONFIG_FILENAME environment variable, or `mise.toml`.
#[clap(long, verbatim_doc_comment, required = false, value_hint = clap::ValueHint::AnyPath)]
file: Option<PathBuf>,
/// Prompt for environment variable values
#[clap(long)]
prompt: bool,
/// Remove the environment variable from config file
///
/// Can be used multiple times.
#[clap(long, value_name = "ENV_KEY", verbatim_doc_comment, visible_aliases = ["rm", "unset"], hide = true)]
remove: Option<Vec<String>>,
/// Read the value from stdin (for multiline input)
///
/// When using --stdin, provide a single key without a value.
/// The value will be read from stdin until EOF.
#[clap(long, conflicts_with = "prompt", requires = "env_vars")]
stdin: bool,
}
impl Set {
/// Decrypt a value if it's encrypted, otherwise return it as-is
async fn decrypt_value_if_needed(
key: &str,
value: &str,
directive: Option<&EnvDirective>,
) -> Result<String> {
// If we have an Age directive, use the specialized decryption
if let Some(EnvDirective::Age { .. }) = directive {
agecrypt::decrypt_age_directive(directive.unwrap())
.await
.map_err(|e| eyre!("[experimental] Failed to decrypt {}: {}", key, e))
}
// Not encrypted, return as-is
else {
Ok(value.to_string())
}
}
pub async fn run(mut self) -> Result<()> {
if self.complete {
return self.complete().await;
}
match (&self.remove, &self.env_vars) {
(None, None) => {
return self.list_all().await;
}
(None, Some(env_vars))
if env_vars.iter().all(|ev| ev.value.is_none()) && !self.prompt && !self.stdin =>
{
return self.get().await;
}
_ => {}
}
let filename = self.filename()?;
let mut mise_toml = get_mise_toml(&filename)?;
if let Some(env_names) = &self.remove {
for name in env_names {
mise_toml.remove_env(name)?;
}
}
if let Some(env_vars) = &self.env_vars
&& env_vars.len() == 1
&& env_vars[0].value.is_none()
&& !self.prompt
&& !self.stdin
{
let key = &env_vars[0].key;
// Use Config's centralized env loading which handles decryption
let full_config = Config::get().await?;
let env = full_config.env().await?;
match env.get(key) {
Some(value) => {
miseprintln!("{value}");
}
None => bail!("Environment variable {key} not found"),
}
return Ok(());
}
if let Some(mut env_vars) = self.env_vars.take() {
// Prompt for values if requested
if self.prompt {
let theme = crate::ui::theme::get_theme();
for ev in &mut env_vars {
if ev.value.is_none() {
let prompt_msg = format!("Enter value for {}", ev.key);
let value = Input::new(&prompt_msg)
.password(self.age_encrypt) // Mask input if encrypting
.theme(&theme)
.run()?;
ev.value = Some(value);
}
}
}
// Read value from stdin if requested
if self.stdin {
if env_vars.len() != 1 {
bail!("--stdin requires exactly one environment variable key");
}
let ev = &mut env_vars[0];
if ev.value.is_some() {
bail!(
"--stdin reads the value from stdin; do not provide a value with KEY=VALUE syntax"
);
}
let mut value = String::new();
std::io::stdin().read_to_string(&mut value)?;
// Strip a single trailing newline (matches `gh secret set` behavior)
if value.ends_with("\r\n") {
value.truncate(value.len() - 2);
} else if value.ends_with('\n') {
value.truncate(value.len() - 1);
}
ev.value = Some(value);
}
// Handle age encryption if requested
if self.age_encrypt {
Settings::get().ensure_experimental("age encryption")?;
// Collect recipients once before the loop to avoid repeated I/O
let recipients = self.collect_age_recipients().await?;
for ev in env_vars {
match ev.value {
Some(value) => {
let age_directive =
agecrypt::create_age_directive(ev.key.clone(), &value, &recipients)
.await?;
if let crate::config::env_directive::EnvDirective::Age {
value: encrypted_value,
format,
..
} = age_directive
{
mise_toml.update_env_age(&ev.key, &encrypted_value, format)?;
}
}
None => bail!("{} has no value", ev.key),
}
}
} else {
for ev in env_vars {
match ev.value {
Some(value) => mise_toml.update_env(&ev.key, value)?,
None => bail!("{} has no value", ev.key),
}
}
}
}
mise_toml.save()
}
async fn complete(&self) -> Result<()> {
for ev in self.cur_env().await? {
println!("{}", ev.key);
}
Ok(())
}
async fn list_all(self) -> Result<()> {
let env = self.cur_env().await?;
let mut table = tabled::Table::new(env);
table::default_style(&mut table, false);
miseprintln!("{table}");
Ok(())
}
async fn get(self) -> Result<()> {
// Determine config file path before moving env_vars
let config_path = if let Some(file) = &self.file {
Some(file.clone())
} else if self.env.is_some() {
Some(self.filename()?)
} else if !self.global {
// Check for local config file when no specific file or environment is specified
// Check for mise.toml in current directory first
let cwd = env::current_dir()?;
let mise_toml = cwd.join("mise.toml");
if mise_toml.exists() {
Some(mise_toml)
} else {
// Fall back to .mise.toml if mise.toml doesn't exist
let dot_mise_toml = cwd.join(".mise.toml");
if dot_mise_toml.exists() {
Some(dot_mise_toml)
} else {
None // Fall back to global config if no local config exists
}
}
} else {
None
};
let filter = self.env_vars.unwrap();
// Handle global config case first
if config_path.is_none() {
let config = Config::get().await?;
let env_with_sources = config.env_with_sources().await?;
// env_with_sources already contains decrypted values
for eva in filter {
if let Some((value, _source)) = env_with_sources.get(&eva.key) {
miseprintln!("{value}");
} else {
bail!("Environment variable {} not found", eva.key);
}
}
return Ok(());
}
// Get the config to access directives directly
let config = MiseToml::from_file(&config_path.unwrap()).unwrap_or_default();
// For local configs, check directives directly
let env_entries = config.env_entries()?;
for eva in filter {
match env_entries.iter().find_map(|ev| match ev {
EnvDirective::Val(k, v, _) if k == &eva.key => Some((v.clone(), Some(ev))),
EnvDirective::Age {
key: k, value: v, ..
} if k == &eva.key => Some((v.clone(), Some(ev))),
_ => None,
}) {
Some((value, directive)) => {
// this does not obey strict=false, since we want to fail if the decryption fails
let decrypted =
Self::decrypt_value_if_needed(&eva.key, &value, directive).await?;
miseprintln!("{decrypted}");
}
None => bail!("Environment variable {} not found", eva.key),
}
}
Ok(())
}
async fn cur_env(&self) -> Result<Vec<Row>> {
let rows = if let Some(file) = &self.file {
let config = MiseToml::from_file(file).unwrap_or_default();
config
.env_entries()?
.into_iter()
.filter_map(|ed| match ed {
EnvDirective::Val(key, value, _) => Some(Row {
key,
value,
source: display_path(file),
}),
EnvDirective::Age { key, value, .. } => Some(Row {
key,
value,
source: display_path(file),
}),
_ => None,
})
.collect()
} else if self.env.is_some() {
// When -E flag is used, read from the environment-specific file
let filename = self.filename()?;
let config = MiseToml::from_file(&filename).unwrap_or_default();
config
.env_entries()?
.into_iter()
.filter_map(|ed| match ed {
EnvDirective::Val(key, value, _) => Some(Row {
key,
value,
source: display_path(&filename),
}),
EnvDirective::Age { key, value, .. } => Some(Row {
key,
value,
source: display_path(&filename),
}),
_ => None,
})
.collect()
} else {
Config::get()
.await?
.env_with_sources()
.await?
.iter()
.map(|(key, (value, source))| Row {
key: key.clone(),
value: value.clone(),
source: display_path(source),
})
.collect()
};
Ok(rows)
}
fn filename(&self) -> Result<PathBuf> {
let opts = ConfigPathOptions {
global: self.global,
path: self.file.clone(),
env: self.env.clone(),
cwd: None, // Use current working directory
prefer_toml: true, // mise set only works with TOML files
prevent_home_local: true, // When in HOME, use global config
};
resolve_target_config_path(opts)
}
async fn collect_age_recipients(&self) -> Result<Vec<Box<dyn age::Recipient + Send>>> {
use age::Recipient;
let mut recipients: Vec<Box<dyn Recipient + Send>> = Vec::new();
// Add x25519 recipients from command line
for recipient_str in &self.age_recipient {
if let Some(recipient) = agecrypt::parse_recipient(recipient_str)? {
recipients.push(recipient);
}
}
// Add SSH recipients from command line
for ssh_arg in &self.age_ssh_recipient {
let path = Path::new(ssh_arg);
if path.exists() {
// It's a file path
recipients.push(agecrypt::load_ssh_recipient_from_path(path).await?);
} else {
// Try to parse as a direct SSH public key
if let Some(recipient) = agecrypt::parse_recipient(ssh_arg)? {
recipients.push(recipient);
}
}
}
// If no recipients were provided, use defaults
if recipients.is_empty()
&& (self.age_recipient.is_empty()
&& self.age_ssh_recipient.is_empty()
&& self.age_key_file.is_none())
{
recipients = agecrypt::load_recipients_from_defaults().await?;
}
// Load recipients from key file if specified
if let Some(key_file) = &self.age_key_file {
let key_file_recipients = agecrypt::load_recipients_from_key_file(key_file).await?;
recipients.extend(key_file_recipients);
}
if recipients.is_empty() {
bail!(
"[experimental] No age recipients provided. Use --age-recipient, --age-ssh-recipient, or --age-key-file"
);
}
Ok(recipients)
}
}
fn get_mise_toml(filename: &Path) -> Result<MiseToml> {
let path = env::current_dir()?.join(filename);
let mise_toml = if path.exists() {
MiseToml::from_file(&path)?
} else {
MiseToml::init(&path)
};
Ok(mise_toml)
}
#[derive(Tabled, Debug, Clone)]
struct Row {
key: String,
value: String,
source: String,
}
static AFTER_LONG_HELP: &str = color_print::cstr!(
r#"<bold><underline>Examples:</underline></bold>
$ <bold>mise set NODE_ENV=production</bold>
$ <bold>mise set NODE_ENV</bold>
production
$ <bold>mise set -E staging NODE_ENV=staging</bold>
# creates or modifies mise.staging.toml
$ <bold>mise set</bold>
key value source
NODE_ENV production ~/.config/mise/config.toml
$ <bold>mise set --prompt PASSWORD</bold>
Enter value for PASSWORD: [hidden input]
<bold><underline>Multiline Values (--stdin):</underline></bold>
$ <bold>cat private.key | mise set --stdin MY_KEY</bold>
$ <bold>printf "line1\nline2" | mise set --stdin MY_KEY</bold>
<bold><underline>[experimental] Age Encryption:</underline></bold>
$ <bold>mise set --age-encrypt API_KEY=secret</bold>
$ <bold>mise set --age-encrypt --prompt API_KEY</bold>
Enter value for API_KEY: [hidden input]
"#
);