sopsy 1.2.0

The missing developer experience for SOPS
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
//! Command-line interface definition (clap derive).
//!
//! Every interactive prompt in sopsy has an equivalent flag here so the tool is
//! fully scriptable. Global flags ([`GlobalArgs`]) control color, verbosity and
//! interactivity and are flattened into the top-level [`Cli`].
//!
//! Non-interactivity is auto-enabled when stdout is not a TTY; see
//! [`GlobalArgs::resolve_interactive`].

use std::io::IsTerminal;
use std::path::PathBuf;

use clap::{Args, Parser, Subcommand};

use crate::sops::FileType;

/// Top-level CLI parser.
#[derive(Debug, Parser)]
#[command(name = "sopsy")]
#[command(version)]
#[command(about = "The missing developer experience for SOPS")]
#[command(propagate_version = true)]
// Wrap help output at 80 columns (capped, so narrower terminals still wrap to
// their width). Set on the root command, this applies to every subcommand too.
#[command(max_term_width = 80)]
pub struct Cli {
    /// Global flags shared by every subcommand.
    #[command(flatten)]
    pub global: GlobalArgs,

    /// The subcommand to run.
    #[command(subcommand)]
    pub command: Command,
}

/// Flags available on every subcommand.
#[derive(Debug, Args, Clone)]
pub struct GlobalArgs {
    /// Disable all interactive prompts; fail instead of asking. Also enabled
    /// automatically when stdout is not a TTY. Aliased as `--yes`/`-y`.
    #[arg(long, short = 'y', visible_alias = "yes", global = true)]
    pub non_interactive: bool,

    /// Disable colored output (also honors the `NO_COLOR` environment variable).
    #[arg(long, global = true)]
    pub no_color: bool,

    /// Increase output verbosity (show debug detail).
    #[arg(long, short = 'v', global = true)]
    pub verbose: bool,
}

impl GlobalArgs {
    /// Resolve whether interactive prompting is allowed, accounting for the
    /// explicit flag and TTY detection.
    pub fn resolve_interactive(&self) -> bool {
        !self.non_interactive && std::io::stdout().is_terminal()
    }

    /// Resolve whether color should be used (subject to further `NO_COLOR`/TTY
    /// checks performed inside [`crate::ui::Ui::new`]).
    pub fn resolve_color(&self) -> bool {
        !self.no_color
    }
}

/// All sopsy subcommands.
#[derive(Debug, Subcommand)]
pub enum Command {
    /// Bootstrap an encrypted repository (tools, identity, `.sops.yaml`, …).
    Init(InitArgs),

    /// Run health checks on the local setup and repository.
    Doctor,

    /// Edit an encrypted file with your editor via `sops`.
    Edit(EditArgs),

    /// Request membership: generate an identity and record a pending entry.
    #[command(visible_alias = "request-access")]
    Join(JoinArgs),

    /// Approve a pending member: re-key secrets so they can decrypt.
    Approve(ApproveArgs),

    /// Manage repository recipients (add/remove/list).
    #[command(subcommand)]
    Recipient(RecipientCommand),

    /// Encrypt or decrypt a secrets file to stdout (or a file).
    #[command(subcommand)]
    Secrets(SecretsCommand),

    /// List the file types sopsy understands (for `--type`).
    ListSupportedTypes,

    /// CI gate: verify the repo's encrypted-secrets hygiene (exit 0/1).
    Check,

    /// Install sopsy's external tools (sops, age, age-plugin-se) via Homebrew.
    Deps(DepsArgs),

    /// Generate a shell completion script (bash, zsh, fish, …).
    Completion(CompletionArgs),
}

/// Arguments for `sopsy init`.
#[derive(Debug, Args)]
pub struct InitArgs {
    /// Name to record for the recipient created/registered during init.
    #[arg(long)]
    pub recipient_name: Option<String>,

    /// Username of who is generating the key (recorded in `.sopsy.yml`). When a
    /// new identity is generated interactively, this is the default offered at
    /// the prompt; falls back to the current system user.
    #[arg(long)]
    pub username: Option<String>,

    /// Use an existing age public key instead of generating a new identity.
    #[arg(long)]
    pub public_key: Option<String>,

    /// Skip Secure Enclave identity generation (e.g. when supplying a key).
    #[arg(long)]
    pub no_generate: bool,

    /// Generate a break-glass emergency key as part of init (the default in
    /// interactive mode is to prompt). Mutually exclusive with `--no-break-glass`.
    #[arg(long, conflicts_with = "no_break_glass")]
    pub break_glass: bool,

    /// Skip break-glass key generation during init.
    #[arg(long)]
    pub no_break_glass: bool,

    /// Proceed even if some doctor checks fail.
    #[arg(long)]
    pub force: bool,
}

/// Arguments for `sopsy edit`.
#[derive(Debug, Args)]
pub struct EditArgs {
    /// The encrypted file to edit.
    pub file: PathBuf,

    /// Editor to use (overrides `$EDITOR`); falls back to a sensible default.
    #[arg(long)]
    pub editor: Option<String>,

    /// Extra arguments forwarded verbatim to `sops` after `--`.
    #[arg(last = true)]
    pub sops_args: Vec<String>,
}

/// Arguments for `sopsy join`.
#[derive(Debug, Args)]
pub struct JoinArgs {
    /// Your name as recorded in `.sopsy.yml` (full name or first name,
    /// e.g. `"Konstantin Gredeskoul"` or `annie`).
    pub name: String,

    /// System username recorded alongside the name (defaults to `$USER`).
    #[arg(long)]
    pub username: Option<String>,

    /// Path to the `.sopsy.yml` to update (defaults to the one in the repo root).
    #[arg(long)]
    pub sopsy_file: Option<PathBuf>,

    /// Use this existing age public key instead of generating a new identity.
    #[arg(long)]
    pub public_key: Option<String>,

    /// Extra arguments forwarded verbatim to `age-plugin-se keygen` after `--`.
    #[arg(last = true)]
    pub age_args: Vec<String>,
}

/// Arguments for `sopsy approve`.
#[derive(Debug, Args)]
pub struct ApproveArgs {
    /// The pending member(s) to approve. Pass several to approve them together
    /// and re-key once: `sopsy approve annie colin`. With no names, walk every
    /// pending member interactively and approve the ones you confirm.
    #[arg(num_args = 0..)]
    pub names: Vec<String>,

    /// Approve even if a join request is older than the configured window.
    #[arg(long)]
    pub force: bool,

    /// Skip running `sops updatekeys` after editing `.sops.yaml`.
    #[arg(long)]
    pub no_updatekeys: bool,
}

/// `sopsy secrets` subcommands.
#[derive(Debug, Subcommand)]
pub enum SecretsCommand {
    /// Encrypt a plaintext file (`.env`/YAML/JSON) to stdout (or `-o <file>`).
    Encrypt(SecretsEncryptArgs),

    /// Decrypt an encrypted file to stdout (or `-o <file>`).
    Decrypt(SecretsDecryptArgs),
}

/// Arguments for `sopsy secrets encrypt`.
#[derive(Debug, Args)]
pub struct SecretsEncryptArgs {
    /// The plaintext file to encrypt (e.g. `.env`, `config.yaml`, `data.json`).
    pub file: PathBuf,

    /// Write the ciphertext to this file (must end in `.encrypted`) instead of
    /// stdout. The committed artifact, e.g. `-o .env.encrypted`.
    #[arg(short = 'o', long = "output")]
    pub output: Option<PathBuf>,

    /// Override the file type (inferred from `<file>`'s extension otherwise).
    #[arg(long = "type", value_enum)]
    pub file_type: Option<FileType>,
}

/// Arguments for `sopsy secrets decrypt`.
#[derive(Debug, Args)]
pub struct SecretsDecryptArgs {
    /// The encrypted file to decrypt.
    pub file: PathBuf,

    /// Write the plaintext here instead of stdout.
    #[arg(short = 'o', long = "output")]
    pub output: Option<PathBuf>,

    /// Override the detected file type (when the name has no usable extension).
    #[arg(long = "type", value_enum)]
    pub file_type: Option<FileType>,
}

/// Arguments for `sopsy deps`.
#[derive(Debug, Args)]
pub struct DepsArgs {
    /// Only report which dependencies are missing; do not install anything.
    /// Exits non-zero if any are missing (handy in CI / pre-flight checks).
    #[arg(long)]
    pub check: bool,

    /// Print the `brew install` command that would run, without executing it.
    #[arg(long)]
    pub dry_run: bool,
}

/// Arguments for `sopsy completion`.
#[derive(Debug, Args)]
pub struct CompletionArgs {
    /// The shell to generate a completion script for.
    #[arg(value_enum)]
    pub shell: clap_complete::Shell,
}

/// `sopsy recipient` subcommands.
#[derive(Debug, Subcommand)]
pub enum RecipientCommand {
    /// Add a recipient and re-encrypt secrets (`sops updatekeys -r .`).
    Add(RecipientAddArgs),

    /// Remove a recipient and re-encrypt secrets.
    Remove(RecipientRemoveArgs),

    /// List configured recipients.
    List,

    /// Generate a new Secure Enclave identity and print its public key.
    Keygen(RecipientKeygenArgs),

    /// Generate a portable break-glass emergency key for offline storage.
    BreakGlass(RecipientBreakGlassArgs),

    /// Generate a portable CI decryption key and register it as a recipient
    /// (store the private half as a CI secret named `SOPS_AGE_KEY`).
    Ci(RecipientCiArgs),
}

/// Arguments for `sopsy recipient keygen`.
#[derive(Debug, Args)]
pub struct RecipientKeygenArgs {
    /// Extra arguments forwarded verbatim to `age-plugin-se keygen` after `--`
    /// (e.g. `--access-control=any-biometry-or-passcode`).
    #[arg(last = true)]
    pub age_args: Vec<String>,
}

/// Arguments for `sopsy recipient break-glass`.
#[derive(Debug, Args)]
pub struct RecipientBreakGlassArgs {
    /// Output path prefix; writes `<output>.private` and `<output>.public`.
    /// Both files are deleted from disk after you confirm they are stored safely.
    #[arg(short = 'o', long = "output")]
    pub output: PathBuf,

    /// Recipient name to record (defaults to `break-glass`).
    #[arg(long = "name")]
    pub name: Option<String>,

    /// Overwrite the `<output>.private` / `<output>.public` files if they exist.
    #[arg(long)]
    pub force: bool,

    /// Skip running `sops updatekeys` after editing `.sops.yaml`.
    #[arg(long)]
    pub no_updatekeys: bool,
}

/// Arguments for `sopsy recipient ci`.
#[derive(Debug, Args)]
pub struct RecipientCiArgs {
    /// Output path prefix; writes `<output>.private` and `<output>.public`.
    /// Both files are deleted from disk after you confirm the private key is
    /// stored in your CI provider's secret store.
    #[arg(short = 'o', long = "output", default_value = "ci")]
    pub output: PathBuf,

    /// Recipient name to record (defaults to `ci`).
    #[arg(long = "name")]
    pub name: Option<String>,

    /// Overwrite the `<output>.private` / `<output>.public` files if they exist.
    #[arg(long)]
    pub force: bool,

    /// Skip running `sops updatekeys` after editing `.sops.yaml`.
    #[arg(long)]
    pub no_updatekeys: bool,
}

/// Arguments for `sopsy recipient add`.
#[derive(Debug, Args)]
pub struct RecipientAddArgs {
    /// Positional recipient name (equivalent to `--name`).
    pub name_pos: Option<String>,

    /// Recipient name.
    #[arg(long = "name")]
    pub name: Option<String>,

    /// The recipient's age public key (`age1...`).
    #[arg(long)]
    pub public_key: Option<String>,

    /// Mark this recipient as the break-glass emergency key.
    #[arg(long)]
    pub break_glass: bool,

    /// Skip running `sops updatekeys` after editing `.sops.yaml`.
    #[arg(long)]
    pub no_updatekeys: bool,
}

impl RecipientAddArgs {
    /// The effective recipient name from either the positional or `--name`.
    pub fn resolved_name(&self) -> Option<&str> {
        self.name.as_deref().or(self.name_pos.as_deref())
    }
}

/// Arguments for `sopsy recipient remove`.
#[derive(Debug, Args)]
pub struct RecipientRemoveArgs {
    /// Positional recipient name (equivalent to `--name`).
    pub name_pos: Option<String>,

    /// Recipient name to remove.
    #[arg(long = "name")]
    pub name: Option<String>,

    /// Skip running `sops updatekeys` after editing `.sops.yaml`.
    #[arg(long)]
    pub no_updatekeys: bool,
}

impl RecipientRemoveArgs {
    /// The effective recipient name from either the positional or `--name`.
    pub fn resolved_name(&self) -> Option<&str> {
        self.name.as_deref().or(self.name_pos.as_deref())
    }
}

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

    #[test]
    fn cli_definition_is_valid() {
        // clap's debug assertions catch malformed derive definitions.
        Cli::command().debug_assert();
    }

    #[test]
    fn parses_recipient_add_with_flags() {
        let cli = Cli::try_parse_from([
            "sopsy",
            "--non-interactive",
            "recipient",
            "add",
            "--name",
            "alice",
            "--public-key",
            "age1alice",
        ])
        .unwrap();
        assert!(cli.global.non_interactive);
        match cli.command {
            Command::Recipient(RecipientCommand::Add(args)) => {
                assert_eq!(args.resolved_name(), Some("alice"));
                assert_eq!(args.public_key.as_deref(), Some("age1alice"));
            }
            _ => panic!("expected recipient add"),
        }
    }

    #[test]
    fn yes_alias_enables_non_interactive() {
        let cli = Cli::try_parse_from(["sopsy", "-y", "doctor"]).unwrap();
        assert!(cli.global.non_interactive);
    }

    #[test]
    fn request_access_is_an_alias_for_join() {
        let cli = Cli::try_parse_from(["sopsy", "request-access", "annie"]).unwrap();
        assert!(matches!(cli.command, Command::Join(args) if args.name == "annie"));
    }

    #[test]
    fn join_accepts_username_flag() {
        let cli = Cli::try_parse_from([
            "sopsy",
            "join",
            "Konstantin Gredeskoul",
            "--username",
            "kig",
        ])
        .unwrap();
        match cli.command {
            Command::Join(args) => {
                assert_eq!(args.name, "Konstantin Gredeskoul");
                assert_eq!(args.username.as_deref(), Some("kig"));
            }
            _ => panic!("expected join"),
        }
    }

    #[test]
    fn approve_accepts_multiple_names() {
        let cli = Cli::try_parse_from(["sopsy", "approve", "annie", "colin"]).unwrap();
        assert!(matches!(cli.command, Command::Approve(args) if args.names == ["annie", "colin"]));
    }

    #[test]
    fn approve_accepts_no_names_for_interactive_mode() {
        let cli = Cli::try_parse_from(["sopsy", "approve"]).unwrap();
        assert!(matches!(cli.command, Command::Approve(args) if args.names.is_empty()));
    }

    #[test]
    fn approve_keeps_multiword_names_intact() {
        // The shell delivers each quoted name as one argv element, so spaces in a
        // name must survive as a single positional, not split into extra names.
        let cli =
            Cli::try_parse_from(["sopsy", "approve", "Konstantin Gredeskoul", "Colin Powell"])
                .unwrap();
        assert!(matches!(
            cli.command,
            Command::Approve(args) if args.names == ["Konstantin Gredeskoul", "Colin Powell"]
        ));
    }
}