flk 0.7.0

A CLI tool for managing flake.nix devShell environments
Documentation
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
//! # Unfree Allow-List Handler
//!
//! Manage the set of unfree (non-free-licensed) packages a flk environment is
//! permitted to evaluate.
//!
//! Nix refuses unfree packages by default. flk records the exception as an
//! explicit list of package names rather than a blanket `allowUnfree = true`,
//! so every unfree dependency stays visible in the config file and in review:
//!
//! ```nix
//! # .flk/config.nix
//! allowUnfree = ["vscode" "obsidian"];
//! ```
//!
//! The list is turned into a `nixpkgs.config.allowUnfreePredicate` matching on
//! `lib.getName`, so entries are package *names* (`vscode`), not attribute
//! paths (`pkgs.vscode`) or store-path names with versions.
//!
//! ## Scope
//!
//! An exception belongs to the profile that needs it, so the list normally
//! lives in the profile file next to the package it covers:
//!
//! ```nix
//! # .flk/profiles/rust.nix
//! {pkgs, ...}: {
//!   allowUnfree = ["terraform"];
//!   packages = [pkgs.terraform];
//! }
//! ```
//!
//! Like `flk add`, a bare invocation targets the default profile and `-p`
//! picks another. `--all` writes the environment-wide list instead, which
//! applies to every profile; the two are merged at evaluation time.
//!
//! ## Layouts
//!
//! Profiles live in `.flk/profiles/*.nix` on both layouts, so profile-scoped
//! exceptions work the same either way. Only the environment-wide list moves:
//!
//! - **Slim** (`.flk/config.nix`): the list lives in the config file.
//! - **Legacy** (`.flk/default.nix`): the list lives in the in-repo driver.
//!   Drivers generated before unfree support gain the required
//!   `nixpkgsConfig` plumbing on first write.

use anyhow::{bail, Context, Result};
use clap::Subcommand;
use colored::Colorize;
use regex::{Captures, Regex};
use std::fs;
use std::path::{Path, PathBuf};

use flk::flake::parsers::config as flk_config;
use flk::flake::parsers::config::AllowUnfree;
use flk::flake::parsers::utils;

const LEGACY_PATH: &str = ".flk/default.nix";

/// Unfree allow-list subcommands, shared by `flk unfree` and `flk global unfree`.
#[derive(Subcommand)]
pub enum UnfreeAction {
    /// Allow an unfree package
    Add {
        /// Package name, as reported by `lib.getName` (e.g. `vscode`)
        package: String,

        /// Target profile (defaults to the current or default profile)
        #[arg(short = 'p', long)]
        profile: Option<String>,

        /// Apply to every profile instead of one
        #[arg(long, conflicts_with = "profile")]
        all: bool,
    },
    /// Stop allowing an unfree package
    Remove {
        /// Package name to disallow
        package: String,

        /// Target profile (defaults to the current or default profile)
        #[arg(short = 'p', long)]
        profile: Option<String>,

        /// Apply to every profile instead of one
        #[arg(long, conflicts_with = "profile")]
        all: bool,
    },
    /// List the currently allowed unfree packages
    List {
        /// Profile to inspect (defaults to the current or default profile)
        #[arg(short = 'p', long)]
        profile: Option<String>,

        /// Show only the environment-wide list
        #[arg(long, conflicts_with = "profile")]
        all: bool,
    },
}

/// Where an allow-list lives.
#[derive(Debug, Clone, PartialEq, Eq)]
enum Target {
    /// The environment-wide list, applying to every profile.
    Environment,
    /// A single profile's own list.
    Profile(String),
}

impl Target {
    /// How to name this target in user-facing output.
    fn label(&self) -> String {
        match self {
            Target::Environment => "every profile".to_string(),
            Target::Profile(name) => format!("profile '{}'", name),
        }
    }
}

/// Dispatch an [`UnfreeAction`].
pub fn run(action: UnfreeAction) -> Result<()> {
    match action {
        UnfreeAction::Add {
            package,
            profile,
            all,
        } => run_add(&package, profile, all),
        UnfreeAction::Remove {
            package,
            profile,
            all,
        } => run_remove(&package, profile, all),
        UnfreeAction::List { profile, all } => run_list(profile, all),
    }
}

/// Resolve which list a command operates on.
///
/// Mirrors `flk add`: without `--all`, a bare invocation resolves the current
/// or default profile exactly as package commands do.
fn resolve_target(profile: Option<String>, all: bool) -> Result<Target> {
    if all {
        return Ok(Target::Environment);
    }

    let profile = utils::resolve_profile(profile)?;
    let path = profile_path(&profile);
    if !path.exists() {
        bail!(
            "Profile {} does not exist ({} not found).\n\
             Target another profile with {}, or apply to every profile with {}.",
            profile.cyan(),
            path.display(),
            "-p <profile>".yellow(),
            "--all".yellow()
        );
    }
    Ok(Target::Profile(profile))
}

fn profile_path(profile: &str) -> PathBuf {
    Path::new(".flk/profiles").join(format!("{}.nix", profile))
}

/// Add `package` to a target's unfree allow-list.
pub fn run_add(package: &str, profile: Option<String>, all: bool) -> Result<()> {
    if !is_valid_package_name(package) {
        bail!(
            "Invalid package name '{}'. Expected a nixpkgs package name such as {}.",
            package.cyan(),
            "vscode".green()
        );
    }

    let target = resolve_target(profile, all)?;
    let mut packages = read_list(&target)?;
    if packages.iter().any(|p| p == package) {
        println!(
            "{} '{}' is already allowed for {}.",
            "".blue(),
            package.cyan(),
            target.label()
        );
        return Ok(());
    }

    packages.push(package.to_string());
    packages.sort();
    write_list(&target, &packages)?;

    println!(
        "{} Allowed unfree package '{}' for {}.",
        "".green().bold(),
        package.cyan(),
        target.label()
    );
    println!(
        "{} Install it with {} and reload with {}.",
        "".blue(),
        match &target {
            Target::Profile(name) => format!("flk add {} -p {}", package, name),
            Target::Environment => format!("flk add {}", package),
        }
        .yellow(),
        "refresh".yellow()
    );
    Ok(())
}

/// Remove `package` from a target's unfree allow-list.
pub fn run_remove(package: &str, profile: Option<String>, all: bool) -> Result<()> {
    let target = resolve_target(profile, all)?;
    let mut packages = read_list(&target)?;
    let before = packages.len();
    packages.retain(|p| p != package);

    if packages.len() == before {
        bail!(
            "'{}' is not in the unfree allow-list for {}. Run {} to see what is.",
            package.cyan(),
            target.label(),
            "flk unfree list".yellow()
        );
    }

    write_list(&target, &packages)?;

    println!(
        "{} Disallowed unfree package '{}' for {}.",
        "".green().bold(),
        package.cyan(),
        target.label()
    );
    Ok(())
}

/// Print the environment-wide allow-list, and — unless `--all` — the targeted
/// profile's own list.
///
/// Both are shown because they are merged at evaluation time: seeing only one
/// of them would misreport what the shell actually allows.
pub fn run_list(profile: Option<String>, all: bool) -> Result<()> {
    print_list(
        &Target::Environment,
        &read_allow_unfree(&Target::Environment)?,
    );

    if all {
        return Ok(());
    }

    let target = resolve_target(profile, false)?;
    print_list(&target, &read_allow_unfree(&target)?);
    Ok(())
}

fn print_list(target: &Target, allow_unfree: &AllowUnfree) {
    let scope = match target {
        Target::Environment => "Environment-wide (every profile)".to_string(),
        Target::Profile(name) => format!("Profile '{}'", name),
    };

    match allow_unfree {
        AllowUnfree::All => {
            println!(
                "{} {}: {} unfree packages allowed ({} is set).",
                "".yellow().bold(),
                scope,
                "all".yellow().bold(),
                "allowUnfree = true".yellow()
            );
            println!(
                "  Replace it with a list to keep the exception reviewable: {}",
                "allowUnfree = [\"vscode\"];".green()
            );
        }
        AllowUnfree::List(packages) if packages.is_empty() => {
            println!("{} {}: nothing allowed.", "".blue(), scope);
        }
        AllowUnfree::List(packages) => {
            println!("{} {}:", "".blue(), scope);
            for package in packages {
                println!("- {}", package.cyan());
            }
        }
    }
}

/// Read a target's allow-list, refusing to proceed on a blanket
/// `allowUnfree = true`.
fn read_list(target: &Target) -> Result<Vec<String>> {
    match read_allow_unfree(target)? {
        AllowUnfree::List(packages) => Ok(packages),
        AllowUnfree::All => bail!(
            "{} sets {}, which already allows every unfree package.\n\
             flk manages the list form only — replace it with {} to use {}.",
            target.label(),
            "allowUnfree = true".yellow(),
            "allowUnfree = [];".green(),
            "flk unfree".yellow()
        ),
    }
}

fn read_allow_unfree(target: &Target) -> Result<AllowUnfree> {
    Ok(flk_config::parse_allow_unfree(&read_target(target)?))
}

fn write_list(target: &Target, packages: &[String]) -> Result<()> {
    match target {
        Target::Profile(name) => write_profile(name, packages),
        Target::Environment if flk_config::exists() => {
            flk_config::write_allow_unfree(packages).context("Failed to update .flk/config.nix")
        }
        Target::Environment => write_legacy(packages),
    }
}

/// Read whichever file owns `allowUnfree` for this target and layout.
fn read_target(target: &Target) -> Result<String> {
    let path = match target {
        Target::Profile(name) => profile_path(name),
        Target::Environment if flk_config::exists() => flk_config::config_path().to_path_buf(),
        Target::Environment => {
            return fs::read_to_string(LEGACY_PATH).context(
                "Failed to read .flk/config.nix or .flk/default.nix. Have you run 'flk init'?",
            )
        }
    };

    fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))
}

/// Write the allow-list into a profile file.
fn write_profile(profile: &str, packages: &[String]) -> Result<()> {
    let path = profile_path(profile);
    let content =
        fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))?;

    let content = flk_config::set_allow_unfree_in_profile(&content, packages)
        .with_context(|| format!("Failed to update {}", path.display()))?;

    fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?;
    Ok(())
}

/// Write the allow-list into a legacy in-repo driver (`.flk/default.nix`).
fn write_legacy(packages: &[String]) -> Result<()> {
    let path = Path::new(LEGACY_PATH);
    let content =
        fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;

    let content = ensure_legacy_scaffold(&content)?;
    let content = flk_config::set_allow_unfree_in_text(&content, packages)?;

    fs::write(path, content).with_context(|| format!("Failed to write {}", path.display()))?;
    Ok(())
}

/// Add the `nixpkgsConfig` plumbing to a legacy driver that predates unfree
/// support.
///
/// Without this, writing `allowUnfree` into an old `.flk/default.nix` would be
/// silently inert — the list would be there but nothing would read it.
fn ensure_legacy_scaffold(content: &str) -> Result<String> {
    let mut out = content.to_string();

    if !out.contains("nixpkgsConfig") {
        let anchor = Regex::new(r"(?m)^(\s*)lib\s*=\s*nixpkgs\.lib;\s*$").unwrap();
        if !anchor.is_match(&out) {
            bail!(
                "Could not find the '{}' binding in {}.\n\
                 This driver has been customized; add the unfree plumbing by hand, \
                 or run {} to move to the slim layout.",
                "lib = nixpkgs.lib;",
                LEGACY_PATH,
                "flk migrate".yellow()
            );
        }
        out = anchor
            .replace(&out, |caps: &Captures| {
                let indent = &caps[1];
                format!(
                    "{indent}lib = nixpkgs.lib;\n\
                     \n\
                     {indent}# Unfree packages this environment is allowed to evaluate, by package\n\
                     {indent}# name (`lib.getName`). Managed by `flk unfree`.\n\
                     {indent}allowUnfree = [];\n\
                     {indent}nixpkgsConfig =\n\
                     {indent}  if builtins.isBool allowUnfree\n\
                     {indent}  then {{inherit allowUnfree;}}\n\
                     {indent}  else if allowUnfree == []\n\
                     {indent}  then {{}}\n\
                     {indent}  else {{\n\
                     {indent}    allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) allowUnfree;\n\
                     {indent}  }};",
                    indent = indent
                )
            })
            .to_string();
    }

    if !out.contains("config = nixpkgsConfig;") {
        let import_re =
            Regex::new(r"(?s)(import\s+nixpkgs\s*\{\s*\n(\s*)inherit system overlays;\n)(\s*\};)")
                .unwrap();
        if !import_re.is_match(&out) {
            bail!(
                "Could not find the nixpkgs import in {}.\n\
                 This driver has been customized; add {} to it by hand, \
                 or run {} to move to the slim layout.",
                LEGACY_PATH,
                "config = nixpkgsConfig;".green(),
                "flk migrate".yellow()
            );
        }
        out = import_re
            .replace(&out, |caps: &Captures| {
                format!(
                    "{}{}config = nixpkgsConfig;\n{}",
                    &caps[1], &caps[2], &caps[3]
                )
            })
            .to_string();
    }

    Ok(out)
}

/// Package names are interpolated straight into a Nix string literal, so keep
/// them to the characters nixpkgs actually uses.
fn is_valid_package_name(name: &str) -> bool {
    !name.is_empty()
        && name
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '+'))
}

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

    const LEGACY_TEMPLATE: &str = include_str!("../../templates/default.nix");

    #[test]
    fn rejects_names_that_would_break_the_nix_file() {
        assert!(is_valid_package_name("vscode"));
        assert!(is_valid_package_name("google-chrome"));
        assert!(is_valid_package_name("nodejs_20"));
        assert!(!is_valid_package_name(""));
        assert!(!is_valid_package_name("vs\"code"));
        assert!(!is_valid_package_name("a b"));
        assert!(!is_valid_package_name("${pkgs.hello}"));
    }

    #[test]
    fn current_legacy_template_needs_no_scaffolding() {
        let out = ensure_legacy_scaffold(LEGACY_TEMPLATE).unwrap();
        assert_eq!(out, LEGACY_TEMPLATE);
    }

    /// A `.flk/default.nix` as generated before unfree support existed.
    const OLD_LEGACY: &str = r#"inputs: let
  inherit (inputs) flake-utils nixpkgs profile-lib;
  lib = nixpkgs.lib;
in
  flake-utils.lib.eachDefaultSystem (
    system: let
      overlays = import ./overlays.nix system;

      pkgs = import nixpkgs {
        inherit system overlays;
      };
    in
      {}
  )
"#;

    #[test]
    fn scaffolds_a_pre_unfree_legacy_driver() {
        let out = ensure_legacy_scaffold(OLD_LEGACY).unwrap();
        assert!(out.contains("allowUnfree = [];"));
        assert!(out.contains("allowUnfreePredicate"));
        assert!(out.contains("config = nixpkgsConfig;"));
        // The binding must land in the outer `let`, before `in`.
        let binding = out.find("nixpkgsConfig =").unwrap();
        let in_kw = out.find("\nin\n").unwrap();
        assert!(binding < in_kw);
    }

    #[test]
    fn scaffolding_is_idempotent() {
        let once = ensure_legacy_scaffold(OLD_LEGACY).unwrap();
        let twice = ensure_legacy_scaffold(&once).unwrap();
        assert_eq!(once, twice);
    }

    #[test]
    fn scaffolded_driver_accepts_an_allow_list() {
        let out = ensure_legacy_scaffold(OLD_LEGACY).unwrap();
        let out = flk_config::set_allow_unfree_in_text(&out, &["vscode".to_string()]).unwrap();
        assert_eq!(
            flk_config::parse_allow_unfree(&out),
            AllowUnfree::List(vec!["vscode".to_string()])
        );
        // Still exactly one binding — the insert replaced, not duplicated.
        assert_eq!(out.matches("allowUnfree = [").count(), 1);
    }

    #[test]
    fn refuses_a_customized_driver_rather_than_writing_inert_config() {
        let customized = "inputs: let\n  pkgs = somethingElse;\nin {}\n";
        let err = ensure_legacy_scaffold(customized).unwrap_err().to_string();
        assert!(err.contains("lib = nixpkgs.lib;"));
    }
}