Skip to content

Commit

Permalink
[SM-1097] Add support for manpage generation (#175)
Browse files Browse the repository at this point in the history
## Type of change
```
- [ ] Bug fix
- [x] New feature development
- [ ] Tech debt (refactoring, code cleanup, dependency upgrades, etc)
- [ ] Build/deploy pipeline (DevOps)
- [ ] Other
```

## Objective
Implement support for generating manpages at compile-time. Extracted
from PR #103. The manpages also get generated in CI and uploaded as
artifacts.

Note that because the CLI struct needs to be accessed by a build script,
it had to be extracted to a separate self contained file.

 ## How to generate manpages locally

The manpages get generated by a build script, and are available in the
crate's build script OUT_DIR, to get it:
```
cargo build --message-format json > build.json
jq -r --slurp '.[] | select (.reason == "build-script-executed") | select(.package_id|contains("crates/bws")) .out_dir' build.json
```

The output path is going to be something like
`sdk/target/debug/build/bws-4acb75a675879df1/out`.

Copy them to a better location, and then you can view the pages: 
- `man ./my-pages/bws.1` 
- `man ./my-pages/bws-project.1` 
- `man ./my-pages/bws-secret.1` 
- …

If you install them in a system path, you could also just access them
with `man bws`, but that path is system specific. In Ubuntu I think it
is `/usr/share/man`

## Screenshots

![Screenshot 2023-07-14 at 18 28
54](https://github.com/bitwarden/sdk/assets/725423/549fbaf8-1d1a-4d77-b348-61e0ddba6911)


![image](https://github.com/bitwarden/sdk/assets/725423/6b0725d7-f307-42db-aa24-88aff7245e3b)
  • Loading branch information
dani-garcia authored May 1, 2024
1 parent faa3444 commit 643d40c
Show file tree
Hide file tree
Showing 9 changed files with 322 additions and 230 deletions.
32 changes: 32 additions & 0 deletions .github/workflows/build-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -435,3 +435,35 @@ jobs:
name: THIRDPARTY.html
path: ./crates/bws/THIRDPARTY.html
if-no-files-found: error

manpages:
name: Generate manpages
runs-on: ubuntu-22.04
needs:
- setup
steps:
- name: Checkout repo
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

- name: Install rust
uses: dtolnay/rust-toolchain@be73d7920c329f220ce78e0234b8f96b7ae60248 # stable
with:
toolchain: stable

- name: Cache cargo registry
uses: Swatinem/rust-cache@23bce251a8cd2ffc3c1075eaa2367cf899916d84 # v2.7.3
with:
key: cargo-cli-manpage

- name: Generate manpages
run: |
cargo check -p bws --message-format json > build.json
OUT_DIR=$(jq -r --slurp '.[] | select (.reason == "build-script-executed") | select(.package_id|contains("crates/bws")) .out_dir' build.json)
mv $OUT_DIR/manpages .
- name: Upload artifact
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
with:
name: manpages
path: ./manpages/*
if-no-files-found: error
17 changes: 17 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions crates/bws/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ tokio = { version = "1.36.0", features = ["rt-multi-thread", "macros"] }
toml = "0.8.10"
uuid = { version = "^1.7.0", features = ["serde"] }

[build-dependencies]
bitwarden-cli = { workspace = true }
clap = { version = "4.5.1", features = ["derive", "string"] }
clap_complete = "4.5.0"
clap_mangen = "0.2.20"
uuid = { version = "^1.7.0" }

[dev-dependencies]
tempfile = "3.10.0"

Expand Down
12 changes: 12 additions & 0 deletions crates/bws/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,15 @@ To use a configuration file, utilize docker
```bash
docker run --rm -it -v "$HOME"/.bws:/home/app/.bws bitwarden/bws --help
```

## How to build manpages

The manpages get built during compilation of the `bws` crate through the use of a build script. The
output path of this build script can be located as follows:

```
MANPAGES_DIR=$(cargo build -p bws --message-format json | jq -r --slurp '.[] | select (.reason == "build-script-executed") | select(.package_id|contains("crates/bws")) .out_dir')
```

After running the provided commands, the built manpages should be located in
`$MANPAGES_DIR/manpages`
14 changes: 14 additions & 0 deletions crates/bws/build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
include!("src/cli.rs");

fn main() -> Result<(), std::io::Error> {
use std::{env, fs, path::Path};

let out_dir = env::var_os("OUT_DIR").expect("OUT_DIR exists");
let path = Path::new(&out_dir).join("manpages");
fs::create_dir_all(&path).expect("OUT_DIR is writable");

let cmd = <Cli as clap::CommandFactory>::command();
clap_mangen::generate_to(cmd, &path)?;

Ok(())
}
228 changes: 228 additions & 0 deletions crates/bws/src/cli.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
use std::path::PathBuf;

use bitwarden_cli::Color;
use clap::{ArgGroup, Parser, Subcommand, ValueEnum};
use clap_complete::Shell;
use uuid::Uuid;

pub(crate) const ACCESS_TOKEN_KEY_VAR_NAME: &str = "BWS_ACCESS_TOKEN";
pub(crate) const CONFIG_FILE_KEY_VAR_NAME: &str = "BWS_CONFIG_FILE";
pub(crate) const PROFILE_KEY_VAR_NAME: &str = "BWS_PROFILE";
pub(crate) const SERVER_URL_KEY_VAR_NAME: &str = "BWS_SERVER_URL";

pub(crate) const DEFAULT_CONFIG_FILENAME: &str = "config";
pub(crate) const DEFAULT_CONFIG_DIRECTORY: &str = ".bws";

#[allow(non_camel_case_types)]
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
pub(crate) enum ProfileKey {
server_base,
server_api,
server_identity,
state_file_dir,
}

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
#[allow(clippy::upper_case_acronyms)]
pub(crate) enum Output {
JSON,
YAML,
Env,
Table,
TSV,
None,
}

#[derive(Parser, Debug)]
#[command(name = "bws", version, about = "Bitwarden Secrets CLI", long_about = None)]
pub(crate) struct Cli {
// Optional as a workaround for https://github.com/clap-rs/clap/issues/3572
#[command(subcommand)]
pub(crate) command: Option<Commands>,

#[arg(short = 'o', long, global = true, value_enum, default_value_t = Output::JSON, help="Output format")]
pub(crate) output: Output,

#[arg(short = 'c', long, global = true, value_enum, default_value_t = Color::Auto, help="Use colors in the output")]
pub(crate) color: Color,

#[arg(short = 't', long, global = true, env = ACCESS_TOKEN_KEY_VAR_NAME, hide_env_values = true, help="Specify access token for the service account")]
pub(crate) access_token: Option<String>,

#[arg(
short = 'f',
long,
global = true,
env = CONFIG_FILE_KEY_VAR_NAME,
help = format!("[default: ~/{}/{}] Config file to use", DEFAULT_CONFIG_DIRECTORY, DEFAULT_CONFIG_FILENAME)
)]
pub(crate) config_file: Option<PathBuf>,

#[arg(short = 'p', long, global = true, env = PROFILE_KEY_VAR_NAME, help="Profile to use from the config file")]
pub(crate) profile: Option<String>,

#[arg(short = 'u', long, global = true, env = SERVER_URL_KEY_VAR_NAME, help="Override the server URL from the config file")]
pub(crate) server_url: Option<String>,
}

#[derive(Subcommand, Debug)]
pub(crate) enum Commands {
#[command(long_about = "Configure the CLI", arg_required_else_help(true))]
Config {
name: Option<ProfileKey>,
value: Option<String>,

#[arg(short = 'd', long)]
delete: bool,
},

#[command(long_about = "Generate shell completion files")]
Completions { shell: Option<Shell> },

#[command(long_about = "Commands available on Projects")]
Project {
#[command(subcommand)]
cmd: ProjectCommand,
},
#[command(long_about = "Commands available on Secrets")]
Secret {
#[command(subcommand)]
cmd: SecretCommand,
},
#[command(long_about = "Create a single item (deprecated)", hide(true))]
Create {
#[command(subcommand)]
cmd: CreateCommand,
},
#[command(long_about = "Delete one or more items (deprecated)", hide(true))]
Delete {
#[command(subcommand)]
cmd: DeleteCommand,
},
#[command(long_about = "Edit a single item (deprecated)", hide(true))]
Edit {
#[command(subcommand)]
cmd: EditCommand,
},
#[command(long_about = "Retrieve a single item (deprecated)", hide(true))]
Get {
#[command(subcommand)]
cmd: GetCommand,
},
#[command(long_about = "List items (deprecated)", hide(true))]
List {
#[command(subcommand)]
cmd: ListCommand,
},
}

#[derive(Subcommand, Debug)]
pub(crate) enum SecretCommand {
Create {
key: String,
value: String,

#[arg(help = "The ID of the project this secret will be added to")]
project_id: Uuid,

#[arg(long, help = "An optional note to add to the secret")]
note: Option<String>,
},
Delete {
secret_ids: Vec<Uuid>,
},
#[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))]
Edit {
secret_id: Uuid,
#[arg(long, group = "edit_field")]
key: Option<String>,
#[arg(long, group = "edit_field")]
value: Option<String>,
#[arg(long, group = "edit_field")]
note: Option<String>,
#[arg(long, group = "edit_field")]
project_id: Option<Uuid>,
},
Get {
secret_id: Uuid,
},
List {
project_id: Option<Uuid>,
},
}

#[derive(Subcommand, Debug)]
pub(crate) enum ProjectCommand {
Create {
name: String,
},
Delete {
project_ids: Vec<Uuid>,
},
Edit {
project_id: Uuid,
#[arg(long, group = "edit_field")]
name: String,
},
Get {
project_id: Uuid,
},
List,
}

#[derive(Subcommand, Debug)]
pub(crate) enum ListCommand {
Projects,
Secrets { project_id: Option<Uuid> },
}

#[derive(Subcommand, Debug)]
pub(crate) enum GetCommand {
Project { project_id: Uuid },
Secret { secret_id: Uuid },
}

#[derive(Subcommand, Debug)]
pub(crate) enum CreateCommand {
Project {
name: String,
},
Secret {
key: String,
value: String,

#[arg(long, help = "An optional note to add to the secret")]
note: Option<String>,

#[arg(long, help = "The ID of the project this secret will be added to")]
project_id: Uuid,
},
}

#[derive(Subcommand, Debug)]
pub(crate) enum EditCommand {
#[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))]
Project {
project_id: Uuid,
#[arg(long, group = "edit_field")]
name: String,
},
#[clap(group = ArgGroup::new("edit_field").required(true).multiple(true))]
Secret {
secret_id: Uuid,
#[arg(long, group = "edit_field")]
key: Option<String>,
#[arg(long, group = "edit_field")]
value: Option<String>,
#[arg(long, group = "edit_field")]
note: Option<String>,
#[arg(long, group = "edit_field")]
project_id: Option<Uuid>,
},
}

#[derive(Subcommand, Debug)]
pub(crate) enum DeleteCommand {
Project { project_ids: Vec<Uuid> },
Secret { secret_ids: Vec<Uuid> },
}
Loading

0 comments on commit 643d40c

Please sign in to comment.