Skip to main content

cli/commands/
env.rs

1use clap::{Args, Subcommand, ValueEnum};
2use std::{ffi::OsString, path::PathBuf};
3
4#[derive(Subcommand, Debug)]
5pub enum EnvCommands {
6    /// List all env variables
7    List {
8        /// Show sensitive values instead of redacting them
9        #[arg(long)]
10        reveal: bool,
11    },
12    /// Set a variable in config.toml [env]
13    Set {
14        /// Variable name (e.g. HTTP_PROXY_PORT)
15        key: String,
16        /// Variable value
17        value: String,
18        /// Write directly into the env override file that currently shadows this
19        /// key (global/overlay/project shine.env.toml) instead of refusing
20        #[arg(long)]
21        force: bool,
22    },
23    /// Delete a variable from config.toml [env]
24    Delete {
25        /// Variable name
26        key: String,
27        /// Delete directly from the env override file that currently shadows
28        /// this key (global/overlay/project shine.env.toml) instead of refusing
29        #[arg(long)]
30        force: bool,
31    },
32    /// Get a single variable value
33    Get {
34        /// Variable name
35        key: String,
36    },
37    /// Run a command with the workspace environment
38    Run(EnvRunCommand),
39    /// Create and manage workspace environment definitions
40    Workspace(EnvWorkspaceCommand),
41    /// Transparently proxy selected commands with explicitly injected values
42    Proxy(EnvProxyCommand),
43    /// Manage SSH secret-broker policies and describe workspace requests
44    Broker(EnvBrokerCommand),
45    /// Encrypt, decrypt, export, and manage secret identities
46    Secret(EnvSecretCommand),
47}
48
49#[derive(Args, Debug)]
50pub struct EnvWorkspaceCommand {
51    #[command(subcommand)]
52    pub command: EnvWorkspaceSubcommand,
53}
54
55#[derive(Subcommand, Debug)]
56pub enum EnvWorkspaceSubcommand {
57    /// Create a workspace from conventional dotenv files
58    Init(EnvWorkspaceInitCommand),
59    /// Export one resolved workspace mode without retaining a Shine dependency
60    Export(EnvWorkspaceExportCommand),
61}
62
63#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
64pub enum EnvWorkspaceExportFormat {
65    /// Conventional KEY=VALUE dotenv output
66    Dotenv,
67}
68
69#[derive(Args, Debug)]
70pub struct EnvWorkspaceExportCommand {
71    /// Export format
72    #[arg(long, value_enum, value_name = "FORMAT")]
73    pub format: EnvWorkspaceExportFormat,
74    /// Workspace definition (defaults to the nearest shine.workspace.toml)
75    #[arg(long, value_name = "FILE")]
76    pub workspace: Option<PathBuf>,
77    /// Environment mode to resolve
78    #[arg(long, value_name = "MODE")]
79    pub mode: String,
80    /// Destination dotenv file
81    #[arg(long, value_name = "FILE")]
82    pub output: PathBuf,
83    /// Decrypt and include sealed workspace secrets
84    #[arg(long)]
85    pub include_secrets: bool,
86    /// Replace an existing output file
87    #[arg(long)]
88    pub force: bool,
89    /// Validate and describe the export without writing it
90    #[arg(long)]
91    pub dry_run: bool,
92}
93
94#[derive(Args, Debug)]
95pub struct EnvWorkspaceInitCommand {
96    /// Import .env, .env.local, .env.<mode>, and .env.<mode>.local files
97    #[arg(long)]
98    pub from_dotenv: bool,
99    /// Mode to import (repeatable); modes are discovered when omitted
100    #[arg(long, value_name = "MODE")]
101    pub mode: Vec<String>,
102    /// Import this key as an encrypted workspace secret (repeatable)
103    #[arg(long, value_name = "KEY")]
104    pub secret: Vec<String>,
105    /// Replace generated workspace files that already exist
106    #[arg(long)]
107    pub force: bool,
108    /// Print planned files without writing them
109    #[arg(long)]
110    pub dry_run: bool,
111}
112
113#[derive(Args, Debug)]
114pub struct EnvProxyCommand {
115    #[command(subcommand)]
116    pub command: EnvProxySubcommand,
117}
118
119#[derive(Args, Debug)]
120pub struct EnvBrokerCommand {
121    #[command(subcommand)]
122    pub command: EnvBrokerSubcommand,
123}
124
125#[derive(Subcommand, Debug)]
126pub enum EnvBrokerSubcommand {
127    /// Describe a workspace request without decrypting or running its command
128    Describe {
129        #[arg(long, value_name = "FILE")]
130        workspace: Option<PathBuf>,
131        #[arg(long)]
132        mode: String,
133        #[arg(
134            long,
135            value_name = "KEY",
136            required_unless_present = "release_all_declared",
137            conflicts_with = "release_all_declared"
138        )]
139        release: Vec<String>,
140        /// Release every secret declared by the selected source snapshot
141        #[arg(long)]
142        release_all_declared: bool,
143        #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
144        command: Vec<String>,
145    },
146    /// Manage local SSH secret-broker authorization policies
147    Policy(EnvBrokerPolicyCommand),
148}
149
150#[derive(Args, Debug)]
151pub struct EnvBrokerPolicyCommand {
152    #[command(subcommand)]
153    pub command: EnvBrokerPolicySubcommand,
154}
155
156#[derive(Args, Debug)]
157pub struct EnvBrokerPolicyInput {
158    #[arg(long)]
159    pub name: String,
160    #[arg(long)]
161    pub ssh_target: String,
162    #[arg(long, default_value = "")]
163    pub project: String,
164    #[arg(long, value_name = "FILE")]
165    pub workspace: PathBuf,
166    /// Optionally require the remote workspace file to have this exact path
167    #[arg(long, value_name = "REMOTE_FILE")]
168    pub remote_workspace: Option<String>,
169    #[arg(long)]
170    pub mode: String,
171    #[arg(
172        long,
173        value_name = "KEY",
174        required_unless_present = "release_all_declared",
175        conflicts_with = "release_all_declared"
176    )]
177    pub release: Vec<String>,
178    /// Release every secret declared by the selected source snapshot
179    #[arg(long)]
180    pub release_all_declared: bool,
181    #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
182    pub command: Vec<String>,
183}
184
185#[derive(Subcommand, Debug)]
186pub enum EnvBrokerPolicySubcommand {
187    /// Add a policy generated from a trusted local workspace checkout
188    Add(EnvBrokerPolicyInput),
189    /// Replace a policy from a trusted local workspace checkout
190    Update(EnvBrokerPolicyInput),
191    /// Show whether a trusted local workspace still matches a policy
192    Diff {
193        name: String,
194        #[arg(long, value_name = "FILE")]
195        workspace: PathBuf,
196        #[arg(long)]
197        mode: String,
198        #[arg(
199            long,
200            value_name = "KEY",
201            required_unless_present = "release_all_declared",
202            conflicts_with = "release_all_declared"
203        )]
204        release: Vec<String>,
205        /// Release every secret declared by the selected source snapshot
206        #[arg(long)]
207        release_all_declared: bool,
208        #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
209        command: Vec<String>,
210    },
211    /// List configured policies
212    List,
213    /// Print one policy
214    Info { name: String },
215    /// Remove one policy
216    Remove { name: String },
217}
218
219#[derive(Subcommand, Debug)]
220pub enum EnvProxySubcommand {
221    /// Install a PATH shim and configure its allowed environment values
222    Install {
223        #[arg(value_name = "COMMAND")]
224        command: String,
225        #[arg(long = "with", value_name = "KEY[=ALIAS]", required = true)]
226        with: Vec<String>,
227        /// Store the rule in the current project's shine.config.toml
228        #[arg(long)]
229        project: bool,
230    },
231    /// List installed transparent command proxies
232    List,
233    /// Remove a shine-managed command proxy and its user-level rule
234    Uninstall {
235        #[arg(value_name = "COMMAND")]
236        command: String,
237    },
238    /// Enable secret injection for an installed command proxy
239    Enable {
240        #[arg(value_name = "COMMAND")]
241        command: String,
242        /// Change the rule in the current project's shine.config.toml
243        #[arg(long)]
244        project: bool,
245    },
246    /// Bypass secret injection while retaining the installed command proxy
247    Disable {
248        #[arg(value_name = "COMMAND")]
249        command: String,
250        /// Change the rule in the current project's shine.config.toml
251        #[arg(long)]
252        project: bool,
253    },
254    #[command(hide = true)]
255    Exec {
256        #[arg(long)]
257        target: PathBuf,
258        #[arg(value_name = "COMMAND")]
259        command: String,
260        #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
261        args: Vec<OsString>,
262    },
263}
264
265#[derive(Args, Debug)]
266pub struct EnvSecretCommand {
267    #[command(subcommand)]
268    pub command: EnvSecretSubcommand,
269}
270
271#[derive(Subcommand, Debug)]
272pub enum EnvSecretSubcommand {
273    /// Decode and decrypt an encrypted secret from [env] (GPG or age)
274    Decrypt {
275        /// Variable name containing encrypted ciphertext
276        key: String,
277    },
278    /// Decrypt KEY_SECRET and print shell code that exports KEY
279    Export {
280        /// Variable name to export from KEY_SECRET
281        key: String,
282        /// Export under a different name in the current shell
283        #[arg(long = "as", value_name = "ALIAS")]
284        alias: Option<String>,
285    },
286    /// Encrypt stdin and print ciphertext (GPG by default, or age with --backend age)
287    Encrypt(EnvEncryptCommand),
288    /// Seal pending secrets in workspace environment files
289    Seal(EnvSealCommand),
290    /// Manage age identities used to decrypt age-backed secrets
291    Identity(EnvIdentityCommand),
292}
293
294#[derive(Args, Debug)]
295pub struct EnvEncryptCommand {
296    /// Secret backend to use: "gpg" (default) or "age"
297    #[arg(long)]
298    pub backend: Option<String>,
299    /// Recipient (repeatable): GPG key ID/fingerprint/email, or age recipient
300    #[arg(short = 'r', long = "recipient")]
301    pub recipients: Vec<String>,
302    /// Store the encrypted ciphertext in config.toml [env] instead of printing it
303    #[arg(long)]
304    pub set: Option<String>,
305    /// Read plaintext from an existing config.toml [env] variable instead of stdin
306    #[arg(long)]
307    pub from: Option<String>,
308    /// Write directly into the env override file that currently shadows the
309    /// target key (global/overlay/project shine.env.toml) instead of refusing
310    #[arg(long)]
311    pub force: bool,
312}
313
314#[derive(Args, Debug)]
315pub struct EnvSealCommand {
316    /// Seal only this environment source file
317    #[arg(value_name = "FILE")]
318    pub file: Option<PathBuf>,
319    /// Workspace definition (defaults to the nearest shine.workspace.toml)
320    #[arg(long, value_name = "FILE")]
321    pub workspace: Option<PathBuf>,
322    /// Secret backend: "gpg", "age", or workspace-only "hybrid"
323    #[arg(long)]
324    pub backend: Option<String>,
325    /// Recipient (repeatable): GPG key ID/fingerprint/email, or age recipient
326    #[arg(short = 'r', long = "recipient")]
327    pub recipients: Vec<String>,
328}
329
330#[derive(Args, Debug)]
331pub struct EnvIdentityCommand {
332    #[command(subcommand)]
333    pub command: EnvIdentitySubcommand,
334}
335
336#[derive(Clone, Copy, Debug, Default, ValueEnum)]
337pub enum PhoneIdentityTransport {
338    #[default]
339    Auto,
340    Adb,
341    Qr,
342}
343
344impl PhoneIdentityTransport {
345    pub fn as_str(self) -> &'static str {
346        match self {
347            Self::Auto => "auto",
348            Self::Adb => "adb",
349            Self::Qr => "qr",
350        }
351    }
352}
353
354/// Public recipient format requested from the phone plugin.
355#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, ValueEnum)]
356pub enum PhoneRecipientType {
357    #[default]
358    Tag,
359    Phone,
360}
361
362impl PhoneRecipientType {
363    pub fn as_str(self) -> &'static str {
364        match self {
365            Self::Tag => "tag",
366            Self::Phone => "phone",
367        }
368    }
369}
370
371#[derive(Subcommand, Debug)]
372pub enum EnvIdentitySubcommand {
373    /// Generate a new age identity, optionally backed by Touch ID or a paired phone
374    Init {
375        /// Generate a Secure Enclave identity requiring Touch ID (macOS only)
376        #[arg(long, conflicts_with = "phone")]
377        touch_id: bool,
378        /// Pair a phone-backed identity on Windows or macOS (experimental) and add its public stub to global Shine config
379        #[arg(
380            long,
381            conflicts_with_all = ["touch_id", "access_control", "output", "force"]
382        )]
383        phone: bool,
384        /// Phone recipient: tag (default, age 1.3+, plugin-free encryption) or phone
385        #[arg(
386            long,
387            requires = "phone",
388            conflicts_with_all = ["touch_id", "access_control", "output", "force"],
389            value_enum,
390            value_name = "TYPE"
391        )]
392        recipient_type: Option<PhoneRecipientType>,
393        /// Desktop label shown during phone pairing (defaults to the computer name, or Shine desktop)
394        #[arg(long, requires = "phone", value_name = "LABEL")]
395        label: Option<String>,
396        /// Phone pairing transport: auto, adb, or qr
397        #[arg(long, requires = "phone", value_enum, value_name = "TRANSPORT")]
398        transport: Option<PhoneIdentityTransport>,
399        /// Explicit ADB device serial when more than one device is online
400        #[arg(long, requires = "phone", value_name = "SERIAL")]
401        adb_serial: Option<String>,
402        /// Secure Enclave access control policy (only with --touch-id): any-biometry
403        /// (default), any-biometry-or-passcode, current-biometry, or passcode
404        #[arg(long, value_name = "POLICY")]
405        access_control: Option<String>,
406        /// Output path (defaults to <shine_dir>/age/identity.txt)
407        #[arg(short = 'o', long, value_name = "PATH")]
408        output: Option<PathBuf>,
409        /// Overwrite an existing identity file
410        #[arg(long)]
411        force: bool,
412    },
413    /// Print the recipient(s) for the configured identity file(s)
414    List,
415}
416
417#[derive(Args, Debug)]
418pub struct EnvRunCommand {
419    /// Workspace definition (defaults to the nearest shine.workspace.toml)
420    #[arg(long, value_name = "FILE")]
421    pub workspace: Option<PathBuf>,
422    /// Environment mode used to expand {mode} paths
423    #[arg(long)]
424    pub mode: Option<String>,
425    /// Skip workspace discovery entirely; use only --with values and inherited env
426    #[arg(long, conflicts_with_all = ["workspace", "mode"])]
427    pub no_workspace: bool,
428    /// Inject a config [env] value as KEY or KEY=ALIAS (repeatable)
429    #[arg(long = "with", value_name = "KEY[=ALIAS]")]
430    pub with: Vec<String>,
431    /// Request secrets from the local end of the current shine ssh session
432    #[arg(long)]
433    pub secret_broker: bool,
434    /// Request one session-authorized encrypted key as KEY or KEY=ALIAS
435    #[arg(
436        long = "secret",
437        value_name = "KEY[=ALIAS]",
438        requires = "secret_broker",
439        requires = "no_workspace"
440    )]
441    pub secret: Vec<String>,
442    /// Command and arguments to run
443    #[arg(required = true, trailing_var_arg = true, allow_hyphen_values = true)]
444    pub command: Vec<OsString>,
445}
446
447#[cfg(test)]
448mod phone_recipient_tests {
449    use super::*;
450    use clap::Parser;
451
452    #[derive(Parser)]
453    struct IdentityCli {
454        #[command(flatten)]
455        identity: EnvIdentityCommand,
456    }
457
458    #[test]
459    fn phone_recipient_cli_defaults_and_explicit_types() {
460        for (extra, expected) in [
461            (vec![], PhoneRecipientType::Tag),
462            (vec!["--recipient-type", "tag"], PhoneRecipientType::Tag),
463            (vec!["--recipient-type", "phone"], PhoneRecipientType::Phone),
464        ] {
465            let mut args = vec!["identity", "init", "--phone"];
466            args.extend(extra);
467            let parsed = IdentityCli::try_parse_from(args).unwrap();
468            let EnvIdentitySubcommand::Init { recipient_type, .. } = parsed.identity.command else {
469                panic!("expected init")
470            };
471            assert_eq!(recipient_type.unwrap_or_default(), expected);
472        }
473    }
474
475    #[test]
476    fn phone_recipient_cli_rejects_invalid_combinations() {
477        for args in [
478            vec!["init", "--recipient-type", "tag"],
479            vec!["init", "--phone", "--recipient-type", "unknown"],
480            vec!["init", "--touch-id", "--recipient-type", "tag"],
481            vec![
482                "init",
483                "--recipient-type",
484                "tag",
485                "--output",
486                "identity.txt",
487            ],
488            vec!["init", "--recipient-type", "tag", "--force"],
489            vec![
490                "init",
491                "--recipient-type",
492                "tag",
493                "--access-control",
494                "passcode",
495            ],
496            vec!["init", "--phone", "--touch-id"],
497            vec!["init", "--phone", "--output", "identity.txt"],
498            vec!["init", "--phone", "--force"],
499            vec!["init", "--phone", "--access-control", "passcode"],
500        ] {
501            assert!(
502                IdentityCli::try_parse_from(
503                    std::iter::once("identity").chain(args.iter().copied())
504                )
505                .is_err(),
506                "accepted {args:?}"
507            );
508        }
509    }
510}