eish 0.1.0

Generate self-contained installation scripts (bash, fish, PowerShell) for GitHub release binaries
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
//! Command line front-end for `eish`.
//!
//! Everything the user can configure lives here; the actual work is delegated
//! to the library so that `eish` stays scriptable and testable.

use std::io::Write;
use std::path::PathBuf;
use std::process::ExitCode;

use clap::builder::PossibleValue;
use clap::{Parser, ValueEnum};
use log::{Level, LevelFilter, info};

use eish::github::{Client, release_from_json_file};
use eish::spec::RepoSpec;
use eish::{InstallSpec, Proxy, Resource, Shell};

/// Generate a self-contained installation script for a GitHub release.
///
/// The release is inspected through the GitHub API, the asset that matches a
/// platform is baked into the script, and the result is written to stdout.
#[derive(Debug, Parser)]
#[command(
    name = "eish",
    version = eish::VERSION,
    about = "Generate installation scripts for GitHub release binaries",
    long_about = None,
    after_help = "Examples:\n  \
        eish easy-install/easy-install > install.sh\n  \
        eish cli/cli@v2.40.0 --shell powershell > install.ps1\n  \
        eish owner/repo --shell fish --proxy xget > install.fish\n  \
        eish owner/repo@v1.0.0 | bash"
)]
pub struct Cli {
    /// Repository to install from: `owner/repo`, `owner/repo@tag` or a full
    /// GitHub URL.
    #[arg(value_name = "SPEC")]
    spec: String,

    /// Shell dialect to generate.
    #[arg(short, long, value_enum, default_value_t = Shell::Bash)]
    shell: Shell,

    /// Proxy the generated installer uses by default.
    #[arg(long, value_enum, default_value_t = Proxy::Github)]
    proxy: Proxy,

    /// Tag to install; overrides the `@tag` suffix of SPEC.
    #[arg(long)]
    tag: Option<String>,

    /// Name of the executable inside the archive.
    ///
    /// Inferred from the asset file names when omitted.
    #[arg(short, long)]
    binary: Option<String>,

    /// Install this program, when the release publishes several.
    ///
    /// Matches the name `eish` reports for the asset (`crash` and `crash-full`
    /// are different programs). Use `--list` on the repository to see the
    /// available names.
    #[arg(long, value_name = "NAME")]
    name: Option<String>,

    /// Target triple the generated installer defaults to, skipping detection.
    #[arg(long)]
    target: Option<String>,

    /// Default installation directory baked into the installer.
    #[arg(long, default_value = eish::spec::DEFAULT_INSTALL_DIR)]
    dir: String,

    /// Download release assets or files committed to the repository.
    #[arg(long = "type", value_enum, default_value_t = ResourceType::Release)]
    resource_type: ResourceType,

    /// Branch, tag or commit used by `--type file`.
    #[arg(long = "ref", default_value = "main")]
    reference: String,

    /// Default minimum free disk space, in megabytes (0 disables the check).
    #[arg(long, default_value_t = eish::spec::DEFAULT_MIN_DISK_SPACE_MB)]
    min_disk_space: u64,

    /// Write the script here instead of stdout.
    #[arg(short, long, value_name = "PATH")]
    output: Option<PathBuf>,

    /// GitHub API root, e.g. a GitHub Enterprise or mirror endpoint.
    #[arg(long, default_value = eish::github::DEFAULT_API_BASE)]
    api_base: String,

    /// Read the release from a JSON file instead of calling the API.
    #[arg(long, value_name = "PATH")]
    release_json: Option<PathBuf>,

    /// Name of a release asset to bundle, instead of querying the API.
    ///
    /// Repeat once per file; the target triple is guessed from each name.
    /// This is the only way to generate an installer for a repository that
    /// publishes no GitHub release at all.
    #[arg(long = "asset", value_name = "FILE")]
    assets: Vec<String>,

    /// Only list the platforms that would be supported, don't render.
    #[arg(long)]
    list: bool,

    /// Print progress messages.
    ///
    /// Repeat for more detail. Progress goes to stderr, so it never mixes with
    /// the script or the `--list` table on stdout.
    #[arg(short, long, action = clap::ArgAction::Count)]
    verbose: u8,

    /// Suppress everything but errors.
    #[arg(short, long)]
    quiet: bool,
}

/// Which kind of resource the installer downloads.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResourceType {
    /// A file attached to a GitHub release.
    Release,
    /// A file committed to the repository.
    File,
}
impl ValueEnum for ResourceType {
    fn value_variants<'a>() -> &'a [Self] {
        &[ResourceType::Release, ResourceType::File]
    }

    fn to_possible_value(&self) -> Option<PossibleValue> {
        Some(match self {
            ResourceType::Release => {
                PossibleValue::new("release").help("a file attached to a GitHub release")
            }
            ResourceType::File => {
                PossibleValue::new("file").help("a file committed to the repository (see --ref)")
            }
        })
    }
}

impl Cli {
    /// Install the process-wide logger.
    ///
    /// Progress and warnings go to stderr through [`log`], which keeps stdout
    /// reserved for the generated script and the `--list` table — both are
    /// meant to be piped into something else, and a stray "querying GitHub"
    /// line in the middle of them is at best noise and at worst a parse error.
    ///
    /// The default level is `warn`, so a plain run is silent unless something
    /// needs attention. `-v` raises it to `info` and `-vv` to `debug`; `-q`
    /// drops it to errors only and wins if both are given, since it is the
    /// stricter request. `EISH_LOG` (or `RUST_LOG`) overrides all of this.
    pub fn init_logging(&self) {
        let default = if self.quiet {
            LevelFilter::Error
        } else {
            match self.verbose {
                0 => LevelFilter::Warn,
                1 => LevelFilter::Info,
                _ => LevelFilter::Debug,
            }
        };

        let mut builder = env_logger::Builder::new();
        builder.filter_level(default);
        // Parsed last, so an explicit setting beats the flags above.
        builder.parse_env("EISH_LOG");
        builder.parse_default_env();
        builder.format(|buffer, record| {
            use std::io::Write as _;
            let message = record.args();
            match record.level() {
                Level::Error => writeln!(buffer, "error: {message}"),
                // The message already reads as a sentence, so the level word is
                // only worth printing when it is not the ordinary case.
                Level::Warn => writeln!(buffer, "warning: {message}"),
                _ => writeln!(buffer, "eish: {message}"),
            }
        });
        let _ = builder.try_init();
    }

    /// Run the command, returning the process exit code.
    pub fn run(self) -> ExitCode {
        match self.execute() {
            Ok(()) => ExitCode::SUCCESS,
            Err(error) => {
                eprintln!("error: {error}");
                let mut source = std::error::Error::source(&error);
                while let Some(cause) = source {
                    eprintln!("  caused by: {cause}");
                    source = cause.source();
                }
                ExitCode::FAILURE
            }
        }
    }

    fn execute(&self) -> eish::Result<()> {
        let parsed = RepoSpec::parse(&self.spec)?;

        let mut spec = InstallSpec::new(parsed.owner, parsed.repo)
            .with_shell(self.shell)
            .with_proxy(self.proxy)
            .with_install_dir(self.dir.clone())
            .with_min_disk_space_mb(self.min_disk_space)
            .with_default_target(self.target.clone())
            .with_name(self.name.clone())
            .with_invocation(invocation())
            .with_resource(match self.resource_type {
                ResourceType::Release => Resource::Release,
                ResourceType::File => Resource::File {
                    reference: self.reference.clone(),
                },
            });

        if let Some(binary) = &self.binary {
            spec = spec.with_binary(binary.clone());
        }

        // `--tag` wins over the `@tag` suffix.
        if let Some(tag) = self.tag.clone().or(parsed.tag) {
            spec = spec.with_tag(tag);
        }

        let release = match (&self.release_json, self.assets.is_empty()) {
            // Explicit asset list: no network access at all.
            (_, false) => None,
            (Some(path), _) => Some(release_from_json_file(path)?),
            (None, _) => {
                let client = Client::new().with_api_base(self.api_base.clone());

                // Say which credential is in play, so a later 403 is easy to
                // attribute to the wrong token rather than to rate limiting.
                if let Some(source) = client.credential_source() {
                    info!("using credentials from {source}");
                }

                info!("querying GitHub for {}", spec.slug());
                Some(client.release(&spec.owner, &spec.repo, Some(&spec.tag))?)
            }
        };

        match &release {
            Some(release) => spec.apply_release_checked(release)?,
            None => {
                if spec.apply_assets(self.assets.iter().map(String::as_str)) == 0 {
                    return Err(spec.no_assets_error());
                }
            }
        }

        spec.validate()?;

        if self.list {
            for asset in &spec.assets {
                println!("{}\t{}\t{}", asset.program, asset.target, asset.filename);
            }
            return Ok(());
        }

        info!(
            "found {} target(s) for {} ({})",
            spec.assets.len(),
            spec.slug(),
            spec.resolved_tag.as_deref().unwrap_or(&spec.tag)
        );
        let script = spec.render()?;
        self.write(&script)
    }

    fn write(&self, script: &str) -> eish::Result<()> {
        match &self.output {
            Some(path) => {
                std::fs::write(path, script).map_err(|source| eish::Error::WriteFile {
                    path: path.clone(),
                    source,
                })?;
                info!("wrote {}", path.display());
                Ok(())
            }
            None => {
                let mut out = std::io::stdout().lock();
                out.write_all(script.as_bytes())
                    .map_err(|source| eish::Error::WriteFile {
                        path: PathBuf::from("<stdout>"),
                        source,
                    })?;
                out.flush().map_err(|source| eish::Error::WriteFile {
                    path: PathBuf::from("<stdout>"),
                    source,
                })
            }
        }
    }
}

/// The command line that produced this process, for the generated header.
///
/// Taken from `argv` rather than rebuilt from the parsed options so that it
/// stays exactly what the user typed — including things the spec does not model,
/// such as `--release-json <path>`. The program name is normalised to `eish` so
/// the printed command does not depend on where the binary happens to live.
///
/// Nothing here needs hiding: credentials come from the environment or a helper
/// rather than the command line, so a generated script can be committed as-is.
fn invocation() -> String {
    let mut args = std::env::args();
    let _program = args.next();
    build_invocation(args)
}

/// Build the header command from the arguments after the program name.
///
/// Split out from [`invocation`] so the quoting can be tested without spawning
/// a process.
fn build_invocation(args: impl IntoIterator<Item = String>) -> String {
    std::iter::once("eish".to_string())
        .chain(args.into_iter().map(|arg| quote_arg(&arg)))
        .collect::<Vec<_>>()
        .join(" ")
}

/// Quote a single argument for a POSIX shell if it needs it.
fn quote_arg(arg: &str) -> String {
    let safe = !arg.is_empty()
        && arg
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || "._-/:+@=,".contains(c));

    if safe {
        arg.to_string()
    } else {
        format!("'{}'", arg.replace('\'', r"'\''"))
    }
}

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

    fn invocation(args: &[&str]) -> String {
        build_invocation(args.iter().map(|s| (*s).to_string()))
    }

    #[test]
    fn records_the_arguments_as_typed() {
        assert_eq!(
            invocation(&["acme/tool@v1", "--shell", "fish", "-o", "install.fish"]),
            "eish acme/tool@v1 --shell fish -o install.fish"
        );
    }

    #[test]
    fn quotes_arguments_that_need_it() {
        assert_eq!(
            invocation(&["acme/tool", "--dir", "/opt/my tools"]),
            "eish acme/tool --dir '/opt/my tools'"
        );
        // A quote inside a value needs the POSIX escape dance.
        assert_eq!(invocation(&["a b's"]), r"eish 'a b'\''s'");
        // Unquoted, the shell would expand this before `eish` saw it.
        assert_eq!(invocation(&["--dir", "~/bin"]), "eish --dir '~/bin'");
    }

    #[test]
    fn leaves_ordinary_arguments_unquoted() {
        assert_eq!(
            invocation(&[
                "https://github.com/a/b@v1.2.3",
                "--target=x86_64-unknown-linux-gnu"
            ]),
            "eish https://github.com/a/b@v1.2.3 --target=x86_64-unknown-linux-gnu"
        );
    }

    /// Credentials never reach the command line, so the header is safe to share
    /// as-is. This guards against someone adding a secret-bearing flag later
    /// without thinking about the generated header.
    #[test]
    fn no_cli_option_is_secret() {
        use clap::CommandFactory as _;

        let command = Cli::command();
        let suspicious: Vec<&str> = command
            .get_arguments()
            .filter_map(|arg| arg.get_long())
            .filter(|long| {
                let long = long.to_ascii_lowercase();
                long.contains("token") || long.contains("password") || long.contains("secret")
            })
            .collect();

        assert!(
            suspicious.is_empty(),
            "these options look secret and would be written into generated scripts: {suspicious:?}"
        );
    }
}