aube 1.1.0

Aube — a fast Node.js package manager
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
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
//! `aube config` — read/write settings in `.npmrc`.
//!
//! Four subcommands mirroring pnpm's surface: `get`, `set`, `delete`,
//! `list`. All four share a single idea: the set of "known" settings is
//! derived *dynamically* from [`aube_settings::meta::SETTINGS`], which is
//! generated at build time from the workspace-root `settings.toml`. That
//! means adding a new setting to `settings.toml` automatically teaches
//! this command:
//!
//! - which `.npmrc` keys the setting reads from (so `get`/`delete` can
//!   resolve a canonical name like `autoInstallPeers` to its
//!   `auto-install-peers` alias and vice versa),
//! - what the setting's type is (for future value validation),
//! - and a human-readable description / default for `list --all`.
//!
//! We stay permissive about unknown keys on purpose: pnpm's `.npmrc` is
//! a free-form file, and auth-token entries like
//! `//registry.npmjs.org/:_authToken` are written by name rather than by
//! canonical setting. Unknown keys are accepted verbatim — the registry
//! is there to *enhance* the UX for settings aube models, not to gate the
//! file.

use crate::commands::npmrc::{NpmrcEdit, user_npmrc_path};
use aube_settings::meta as settings_meta;
use clap::{Args, Subcommand, ValueEnum};
use miette::miette;
use std::path::{Path, PathBuf};

#[derive(Debug, Args)]
pub struct ConfigArgs {
    #[command(subcommand)]
    pub command: ConfigCommand,
}

#[derive(Debug, Subcommand)]
pub enum ConfigCommand {
    /// Delete a key from the selected `.npmrc` file
    #[command(visible_aliases = ["rm", "remove", "unset"])]
    Delete(KeyArgs),
    /// Print the effective value of a key
    Get(GetArgs),
    /// Print every key/value from the selected `.npmrc` file(s)
    #[command(visible_alias = "ls")]
    List(ListArgs),
    /// Write a key=value pair to the selected `.npmrc` file
    Set(SetArgs),
}

#[derive(Debug, Args)]
pub struct KeyArgs {
    /// The setting key.
    ///
    /// Accepts either a pnpm canonical name (e.g. `autoInstallPeers`)
    /// or an `.npmrc` alias (e.g. `auto-install-peers`).
    pub key: String,

    /// Shortcut for `--location project`.
    #[arg(long, conflicts_with = "location")]
    pub local: bool,

    /// Which `.npmrc` file to act on.
    ///
    /// Defaults to `user` (`~/.npmrc`), matching pnpm.
    #[arg(long, value_enum, default_value_t = Location::User)]
    pub location: Location,
}

impl KeyArgs {
    fn effective_location(&self) -> Location {
        if self.local {
            Location::Project
        } else {
            self.location
        }
    }
}

#[derive(Debug, Args)]
pub struct GetArgs {
    /// The setting key.
    ///
    /// Accepts either a pnpm canonical name (e.g. `autoInstallPeers`)
    /// or an `.npmrc` alias (e.g. `auto-install-peers`).
    pub key: String,

    /// Emit the value as JSON.
    ///
    /// Matches `pnpm config get --json`: a missing key renders as
    /// `undefined`, a found value is JSON-encoded.
    #[arg(long)]
    pub json: bool,

    /// Shortcut for `--location project`.
    #[arg(long, conflicts_with = "location")]
    pub local: bool,

    /// Which `.npmrc` file(s) to read.
    ///
    /// Defaults to `merged` — the last-write-wins view of `~/.npmrc`
    /// then `./.npmrc`, matching what install actually sees. Use
    /// `user` or `project` to restrict the lookup to a single file.
    #[arg(long, value_enum, default_value_t = ListLocation::Merged)]
    pub location: ListLocation,
}

impl GetArgs {
    fn effective_location(&self) -> ListLocation {
        if self.local {
            ListLocation::Project
        } else {
            self.location
        }
    }
}

#[derive(Debug, Args)]
pub struct SetArgs {
    /// Setting key (canonical name or `.npmrc` alias).
    pub key: String,

    /// Value to write. Stored verbatim after `key=`.
    pub value: String,

    /// Shortcut for `--location project`.
    #[arg(long, conflicts_with = "location")]
    pub local: bool,

    /// Which `.npmrc` file to write to.
    ///
    /// Defaults to `user`.
    #[arg(long, value_enum, default_value_t = Location::User)]
    pub location: Location,
}

impl SetArgs {
    fn effective_location(&self) -> Location {
        if self.local {
            Location::Project
        } else {
            self.location
        }
    }
}

#[derive(Debug, Args)]
pub struct ListArgs {
    /// Also list settings that have no value set — one row per setting
    /// in `settings.toml`, with the default and description rendered.
    ///
    /// Only valid with `--location merged` (the default), since a
    /// per-file view can't distinguish "not set anywhere" from "set in
    /// the other file" and would render misleading defaults.
    #[arg(long)]
    pub all: bool,

    /// Emit all entries as a JSON object keyed by setting name, matching
    /// `pnpm config list --json`.
    ///
    /// Honors `--all` and `--location` the same way the default text
    /// output does.
    #[arg(long)]
    pub json: bool,

    /// Shortcut for `--location project`.
    ///
    /// Conflicts with `--all` since `--all` only makes sense against
    /// the merged view — see the `--all` docs for why.
    #[arg(long, conflicts_with_all = ["location", "all"])]
    pub local: bool,

    /// Which `.npmrc` file(s) to list.
    ///
    /// `merged` (default) walks `~/.npmrc` then the project's
    /// `.npmrc` with last-write-wins precedence, matching how install
    /// reads config.
    #[arg(long, value_enum, default_value_t = ListLocation::Merged)]
    pub location: ListLocation,
}

impl ListArgs {
    fn effective_location(&self) -> ListLocation {
        if self.local {
            ListLocation::Project
        } else {
            self.location
        }
    }
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum Location {
    /// `~/.npmrc`
    User,
    /// `<cwd>/.npmrc`
    Project,
    /// Alias for `user` — aube has no separate global config file.
    Global,
}

#[derive(Debug, Clone, Copy, ValueEnum)]
pub enum ListLocation {
    /// Merge `~/.npmrc` + project `.npmrc`, last-write-wins (same
    /// precedence install uses).
    Merged,
    /// Only `~/.npmrc`
    User,
    /// Only `<cwd>/.npmrc`
    Project,
    /// Alias for `user`.
    Global,
}

impl Location {
    fn path(self) -> miette::Result<PathBuf> {
        match self {
            Location::User | Location::Global => user_npmrc_path(),
            Location::Project => Ok(crate::dirs::project_root_or_cwd()?.join(".npmrc")),
        }
    }
}

pub async fn run(args: ConfigArgs) -> miette::Result<()> {
    match args.command {
        ConfigCommand::Get(a) => get(a),
        ConfigCommand::Set(a) => set(a),
        ConfigCommand::Delete(a) => delete(a),
        ConfigCommand::List(a) => list(a),
    }
}

/// True for entries in `SettingMeta::npmrc_keys` that are real, literal
/// `.npmrc` keys — not pattern templates like `@scope:registry` or
/// `//host/:_authToken`. The `registries` setting documents both the
/// literal key (`registry`) and the templates side-by-side; this filter
/// keeps `resolve_aliases` from treating the templates as sibling
/// aliases of `registry`, which would let `config set @scope:registry …`
/// silently delete the user's real `registry` entry.
fn is_literal_alias(key: &str) -> bool {
    !key.starts_with("//") && !key.contains(':')
}

/// Expand a user-supplied key into the full set of `.npmrc` aliases it
/// covers. If the input matches a setting's canonical name, return its
/// literal `npmrc_keys`. If the input matches one of those literal
/// aliases, return the same set (so `get auto-install-peers` also sees
/// a value written under `autoInstallPeers`). Pattern-template entries
/// in `npmrc_keys` (e.g. `@scope:registry`) are filtered out — see
/// [`is_literal_alias`]. Otherwise fall back to the literal input.
fn resolve_aliases(key: &str) -> Vec<String> {
    if let Some(meta) = settings_meta::find(key) {
        let literals = literal_aliases(meta.npmrc_keys);
        if !literals.is_empty() {
            return literals;
        }
    }
    for meta in settings_meta::all() {
        let literals = literal_aliases(meta.npmrc_keys);
        if literals.iter().any(|a| a == key) {
            return literals;
        }
    }
    vec![key.to_string()]
}

fn literal_aliases(keys: &[&'static str]) -> Vec<String> {
    keys.iter()
        .filter(|k| is_literal_alias(k))
        .map(|s| s.to_string())
        .collect()
}

/// Pick which alias a `set` should write to. Prefers the user-typed key
/// if it's already one of the setting's recognized aliases (so
/// `config set auto-install-peers true` doesn't rewrite it to
/// `autoInstallPeers`), otherwise the first declared alias, otherwise
/// the literal input.
fn preferred_write_key(input: &str, aliases: &[String]) -> String {
    if aliases.iter().any(|a| a == input) {
        return input.to_string();
    }
    aliases
        .first()
        .cloned()
        .unwrap_or_else(|| input.to_string())
}

pub fn get(args: GetArgs) -> miette::Result<()> {
    let aliases = resolve_aliases(&args.key);
    let cwd = crate::dirs::project_root_or_cwd()?;
    // `merged` is the default because "what would install actually
    // see?" is the useful question most of the time. `user` / `project`
    // exist so callers can scope the lookup to a single file (e.g. to
    // answer "is this key set at the project level?") — dispatching
    // per-location here matches what `list` does.
    let entries: Vec<(String, String)> = match args.effective_location() {
        ListLocation::Merged => read_merged(&cwd)?,
        ListLocation::User | ListLocation::Global => read_single(&user_npmrc_path()?)?,
        ListLocation::Project => read_single(&cwd.join(".npmrc"))?,
    };

    // Walk in reverse so the last-written entry wins, matching the
    // precedence install uses.
    for (k, v) in entries.iter().rev() {
        if aliases.iter().any(|a| a == k) {
            if args.json {
                println!("{}", serde_json::Value::String(v.clone()));
            } else {
                println!("{v}");
            }
            return Ok(());
        }
    }
    // pnpm prints `undefined` for a missing key; we match (in both
    // text and JSON modes — `undefined` isn't valid JSON, but that's
    // what pnpm emits and downstream tooling expects it).
    println!("undefined");
    Ok(())
}

pub fn set(args: SetArgs) -> miette::Result<()> {
    let aliases = resolve_aliases(&args.key);
    let write_key = preferred_write_key(&args.key, &aliases);
    let path = args.effective_location().path()?;
    let mut edit = NpmrcEdit::load(&path)?;
    // Remove every known alias before writing so that a prior
    // `auto-install-peers=false` doesn't linger after the user runs
    // `config set autoInstallPeers true`.
    for alias in &aliases {
        if alias != &write_key {
            edit.remove(alias);
        }
    }
    edit.set(&write_key, &args.value);
    edit.save(&path)?;
    eprintln!("set {}={} ({})", write_key, args.value, path.display());
    Ok(())
}

fn delete(args: KeyArgs) -> miette::Result<()> {
    let aliases = resolve_aliases(&args.key);
    let path = args.effective_location().path()?;
    if !path.exists() {
        return Err(miette!("no .npmrc at {}", path.display()));
    }
    let mut edit = NpmrcEdit::load(&path)?;
    let mut removed = false;
    for alias in &aliases {
        if edit.remove(alias) {
            removed = true;
        }
    }
    if !removed {
        return Err(miette!("{} not set in {}", args.key, path.display()));
    }
    edit.save(&path)?;
    eprintln!("deleted {} ({})", args.key, path.display());
    Ok(())
}

fn list(args: ListArgs) -> miette::Result<()> {
    // `--all` only makes sense against the merged view. With a per-file
    // location, a key set in the *other* file looks "unset" to this
    // command and we'd print its default alongside a real value, which
    // is misleading.
    let location = args.effective_location();
    if args.all && !matches!(location, ListLocation::Merged) {
        return Err(miette!(
            "--all is only supported with --location merged (the default)"
        ));
    }
    let cwd = crate::dirs::project_root_or_cwd()?;
    let entries: Vec<(String, String)> = match location {
        ListLocation::Merged => read_merged(&cwd)?,
        ListLocation::User | ListLocation::Global => read_single(&user_npmrc_path()?)?,
        ListLocation::Project => read_single(&cwd.join(".npmrc"))?,
    };

    // Collapse duplicate keys to the last-written value so `list` matches
    // what `get` would print for each key. Cross-alias duplicates collapse
    // too: a setting written under `auto-install-peers` in one file and
    // `autoInstallPeers` in another is a single entry in the output,
    // keyed on the setting's primary alias, with the value of whichever
    // spelling was encountered last. Without this canonicalization,
    // `get auto-install-peers` and `list` could disagree — `get` resolves
    // all aliases, `list` would show both rows unchanged.
    let mut seen: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
    for (k, v) in entries {
        let canonical = canonical_list_key(&k);
        seen.insert(canonical, v);
    }

    // Keys filled in from `settings.toml` defaults rather than from the
    // `.npmrc` file(s). Tracked separately from `seen` so the text output
    // can append a `(default)` annotation while the JSON output emits a
    // plain value — matching `pnpm config list --json --all`, which is
    // shape-clean (no baked-in annotation).
    let mut defaults: std::collections::HashSet<String> = std::collections::HashSet::new();
    if args.all {
        // Merge in every known setting from the registry so the user can
        // see defaults for keys they've never set. Env-only settings
        // (those with no `.npmrc` source) are skipped, and pattern-template
        // entries in `npmrc_keys` are filtered out so `registries` doesn't
        // print a row for `@scope:registry (default)`.
        for meta in settings_meta::all() {
            let literals = literal_aliases(meta.npmrc_keys);
            let Some(primary) = literals.first().cloned() else {
                continue;
            };
            // Don't overwrite a real value with the default.
            if !literals.iter().any(|k| seen.contains_key(k)) {
                seen.insert(primary.clone(), meta.default.to_string());
                defaults.insert(primary);
            }
        }
    }

    if args.json {
        let obj: serde_json::Map<String, serde_json::Value> = seen
            .into_iter()
            .map(|(k, v)| (k, serde_json::Value::String(v)))
            .collect();
        let out = serde_json::to_string_pretty(&serde_json::Value::Object(obj))
            .map_err(|e| miette!("failed to serialize config: {e}"))?;
        println!("{out}");
    } else {
        for (k, v) in &seen {
            if defaults.contains(k) {
                println!("{k}={v} (default)");
            } else {
                println!("{k}={v}");
            }
        }
    }
    Ok(())
}

/// Map a raw `.npmrc` key onto the form `list` should display it under.
/// For a key that matches a setting's `.npmrc` alias set (in either
/// direction), the primary alias (`npmrc_keys[0]`) is returned so that
/// cross-spelling duplicates collapse into a single row. Unknown keys
/// (auth tokens, scoped registries, anything not modeled in
/// `settings.toml`) pass through verbatim.
fn canonical_list_key(key: &str) -> String {
    let aliases = resolve_aliases(key);
    if aliases.len() == 1 && aliases[0] == key {
        // Unknown key: `resolve_aliases` returned the identity fallback.
        return key.to_string();
    }
    aliases.first().cloned().unwrap_or_else(|| key.to_string())
}

/// Read `~/.npmrc` then `<cwd>/.npmrc` and return every entry in file
/// order (user-first, project-second) so a later duplicate wins on a
/// reverse walk. Unlike the install-time reader in `aube_registry`,
/// this path deliberately does **not** substitute `${VAR}` references:
/// `config` commands inspect and mutate the file verbatim, so echoing
/// a resolved token would both surprise users and leak secrets. A
/// per-file `--location` read goes through the same `NpmrcEdit`-based
/// parser, which keeps merged and per-file output consistent.
fn read_merged(cwd: &Path) -> miette::Result<Vec<(String, String)>> {
    let mut out = Vec::new();
    if let Ok(user) = user_npmrc_path() {
        out.extend(read_single(&user)?);
    }
    out.extend(read_single(&cwd.join(".npmrc"))?);
    Ok(out)
}

fn read_single(path: &std::path::Path) -> miette::Result<Vec<(String, String)>> {
    if !path.exists() {
        return Ok(Vec::new());
    }
    // Reuse NpmrcEdit's parser so we share the single source of truth
    // for .npmrc line handling (comments, blanks, key=value splitting).
    let edit = NpmrcEdit::load(path)?;
    Ok(edit.entries())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn canonical_list_key_collapses_alias_to_primary() {
        // autoInstallPeers has two declared aliases, with
        // `auto-install-peers` first → both spellings should canonicalize
        // to the primary.
        assert_eq!(canonical_list_key("autoInstallPeers"), "auto-install-peers");
        assert_eq!(
            canonical_list_key("auto-install-peers"),
            "auto-install-peers"
        );
    }

    #[test]
    fn canonical_list_key_passthrough_for_unknown_key() {
        // Unknown keys (auth tokens, scoped registries) pass through so
        // `config list` still shows them under their literal name.
        assert_eq!(
            canonical_list_key("//registry.example.com/:_authToken"),
            "//registry.example.com/:_authToken"
        );
    }

    #[test]
    fn resolve_aliases_canonical_name() {
        // autoInstallPeers declares both camelCase and kebab-case aliases.
        let aliases = resolve_aliases("autoInstallPeers");
        assert!(aliases.iter().any(|a| a == "auto-install-peers"));
        assert!(aliases.iter().any(|a| a == "autoInstallPeers"));
    }

    #[test]
    fn resolve_aliases_from_alias() {
        // Starting from the alias side must return the same set.
        let aliases = resolve_aliases("auto-install-peers");
        assert!(aliases.iter().any(|a| a == "auto-install-peers"));
        assert!(aliases.iter().any(|a| a == "autoInstallPeers"));
    }

    #[test]
    fn resolve_aliases_registry_excludes_template_keys() {
        // `registries` declares both the literal `registry` key and three
        // pattern templates (`@scope:registry`, `//host/:_authToken`,
        // `//host/:_auth`). Only the literal should land in the alias set
        // — otherwise `config set @scope:registry …` would resolve to the
        // registries group and `config set` would clobber the user's
        // real `registry` entry via the stale-alias removal pass.
        let aliases = resolve_aliases("registry");
        assert_eq!(aliases, vec!["registry".to_string()]);
        for a in &aliases {
            assert!(is_literal_alias(a), "leaked template alias: {a}");
        }
    }

    #[test]
    fn resolve_aliases_template_input_is_identity() {
        // A user typing the template verbatim should be treated as an
        // unknown key (identity fallback), not silently mapped onto the
        // registries alias group.
        for template in [
            "@scope:registry",
            "//registry.example.com/:_authToken",
            "//registry.example.com/:_auth",
        ] {
            assert_eq!(
                resolve_aliases(template),
                vec![template.to_string()],
                "{template} should be identity, not registries-grouped"
            );
        }
    }

    #[test]
    fn is_literal_alias_recognizes_templates() {
        assert!(is_literal_alias("registry"));
        assert!(is_literal_alias("auto-install-peers"));
        assert!(!is_literal_alias("@scope:registry"));
        assert!(!is_literal_alias("//host/:_authToken"));
        assert!(!is_literal_alias("//host/:_auth"));
    }

    #[test]
    fn resolve_aliases_unknown_key_is_identity() {
        let aliases = resolve_aliases("//registry.example.com/:_authToken");
        assert_eq!(
            aliases,
            vec!["//registry.example.com/:_authToken".to_string()]
        );
    }

    #[test]
    fn preferred_write_key_keeps_user_typed_alias() {
        let aliases = vec![
            "auto-install-peers".to_string(),
            "autoInstallPeers".to_string(),
        ];
        assert_eq!(
            preferred_write_key("autoInstallPeers", &aliases),
            "autoInstallPeers"
        );
        assert_eq!(
            preferred_write_key("auto-install-peers", &aliases),
            "auto-install-peers"
        );
    }

    #[test]
    fn preferred_write_key_falls_back_to_first_alias() {
        let aliases = vec![
            "auto-install-peers".to_string(),
            "autoInstallPeers".to_string(),
        ];
        // Input isn't an alias → write to the first declared one.
        assert_eq!(
            preferred_write_key("something-else", &aliases),
            "auto-install-peers"
        );
    }
}