diff --git a/Cargo.lock b/Cargo.lock index 8074b12b1..12b46e106 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5073,6 +5073,7 @@ dependencies = [ "dioxus-check", "dioxus-cli-config", "dioxus-cli-opt", + "dioxus-cli-telemetry", "dioxus-core", "dioxus-core-types", "dioxus-devtools-types", @@ -5088,6 +5089,7 @@ dependencies = [ "env_logger 0.11.8", "escargot", "flate2", + "fs2", "futures-channel", "futures-util", "handlebars", @@ -5112,6 +5114,7 @@ dependencies = [ "path-absolutize", "pdb", "plist", + "posthog-rs", "prettyplease", "proc-macro2", "ratatui", @@ -5121,8 +5124,10 @@ dependencies = [ "rustls 0.23.29", "self-replace", "self_update", + "sentry-backtrace", "serde", "serde_json", + "serde_json5", "shell-words", "strum 0.27.1", "subsecond-types", @@ -5221,6 +5226,18 @@ dependencies = [ "tar", ] +[[package]] +name = "dioxus-cli-telemetry" +version = "0.7.0-alpha.3" +dependencies = [ + "chrono", + "dirs 6.0.0", + "rand 0.9.1", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "dioxus-config-macro" version = "0.7.0-alpha.3" @@ -6990,6 +7007,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_at" version = "0.2.1" @@ -12477,6 +12504,21 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "posthog-rs" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ead24e7bd3ce35a88fffe1f9a4747b4844335fa837eddecf9e7ad9708f3e26b3" +dependencies = [ + "chrono", + "derive_builder", + "reqwest 0.11.27", + "semver", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -14178,6 +14220,46 @@ dependencies = [ "futures-core", ] +[[package]] +name = "sentry-backtrace" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68e299dd3f7bcf676875eee852c9941e1d08278a743c32ca528e2debf846a653" +dependencies = [ + "backtrace", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-core" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deaa38b94e70820ff3f1f9db3c8b0aef053b667be130f618e615e0ff2492cbcc" +dependencies = [ + "sentry-types", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "sentry-types" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e477f4d4db08ddb4ab553717a8d3a511bc9e81dde0c808c680feacbb8105c412" +dependencies = [ + "debugid", + "hex", + "rand 0.9.1", + "serde", + "serde_json", + "thiserror 2.0.12", + "time", + "url", + "uuid", +] + [[package]] name = "separator" version = "0.4.1" @@ -14278,6 +14360,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_json5" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d34d03f54462862f2a42918391c9526337f53171eaa4d8894562be7f252edd3" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "serde_path_to_error" version = "0.1.17" diff --git a/Cargo.toml b/Cargo.toml index 06c12b87c..f49009e72 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,8 @@ members = [ "packages/core-types", "packages/cli", "packages/cli-opt", + "packages/cli-config", + "packages/cli-telemetry", "packages/core-types", "packages/core-macro", "packages/config-macro", @@ -22,8 +24,6 @@ members = [ "packages/liveview", "packages/autofmt", "packages/check", - "packages/cli-config", - "packages/cli", "packages/config-macro", "packages/core-macro", "packages/core-types", @@ -140,6 +140,7 @@ version = "0.7.0-alpha.3" # dependencies that are shared across packages [workspace.dependencies] dioxus = { path = "packages/dioxus", version = "0.7.0-alpha.3" } +dioxus-lib = { path = "packages/dioxus-lib", version = "0.7.0-alpha.3" } dioxus-core = { path = "packages/core", version = "0.7.0-alpha.3" } dioxus-core-types = { path = "packages/core-types", version = "0.7.0-alpha.3" } dioxus-core-macro = { path = "packages/core-macro", version = "0.7.0-alpha.3" } @@ -155,6 +156,7 @@ dioxus-web = { path = "packages/web", version = "0.7.0-alpha.3", default-feature dioxus-isrg = { path = "packages/isrg", version = "0.7.0-alpha.3" } dioxus-ssr = { path = "packages/ssr", version = "0.7.0-alpha.3", default-features = false } dioxus-desktop = { path = "packages/desktop", version = "0.7.0-alpha.3", default-features = false } +dioxus-mobile = { path = "packages/mobile", version = "0.7.0-alpha.3" } dioxus-interpreter-js = { path = "packages/interpreter", version = "0.7.0-alpha.3" } dioxus-liveview = { path = "packages/liveview", version = "0.7.0-alpha.3" } dioxus-autofmt = { path = "packages/autofmt", version = "0.7.0-alpha.3" } @@ -165,8 +167,6 @@ dioxus-rsx-rosetta = { path = "packages/rsx-rosetta", version = "0.7.0-alpha.3" dioxus-signals = { path = "packages/signals", version = "0.7.0-alpha.3" } dioxus-stores = { path = "packages/stores", version = "0.7.0-alpha.3" } dioxus-stores-macro = { path = "packages/stores-macro", version = "0.7.0-alpha.3" } -dioxus-cli-config = { path = "packages/cli-config", version = "0.7.0-alpha.3" } -dioxus-cli-opt = { path = "packages/cli-opt", version = "0.7.0-alpha.3" } dioxus-devtools = { path = "packages/devtools", version = "0.7.0-alpha.3" } dioxus-devtools-types = { path = "packages/devtools-types", version = "0.7.0-alpha.3" } dioxus-server = { path = "packages/server", version = "0.7.0-alpha.3" } @@ -180,11 +180,19 @@ dioxus-native = { path = "packages/native", version = "0.7.0-alpha.3" } dioxus-native-dom = { path = "packages/native-dom", version = "0.7.0-alpha.3" } dioxus-asset-resolver = { path = "packages/asset-resolver", version = "0.7.0-alpha.3" } dioxus-config-macros = { path = "packages/config-macros", version = "0.7.0-alpha.3" } -const-serialize = { path = "packages/const-serialize", version = "0.7.0-alpha.3" } -const-serialize-macro = { path = "packages/const-serialize-macro", version = "0.7.0-alpha.3" } + generational-box = { path = "packages/generational-box", version = "0.7.0-alpha.3" } lazy-js-bundle = { path = "packages/lazy-js-bundle", version = "0.7.0-alpha.3" } +# cli +dioxus-cli-opt = { path = "packages/cli-opt", version = "0.7.0-alpha.0" } +dioxus-cli-telemetry = { path = "packages/cli-telemetry", version = "0.7.0-alpha.0" } +dioxus-cli-config = { path = "packages/cli-config", version = "0.7.0-alpha.0" } + +# const-serializea +const-serialize = { path = "packages/const-serialize", version = "0.7.0-alpha.0" } +const-serialize-macro = { path = "packages/const-serialize-macro", version = "0.7.0-alpha.0" } + # subsecond subsecond-types = { path = "packages/subsecond/subsecond-types", version = "0.7.0-alpha.3" } subsecond = { path = "packages/subsecond/subsecond", version = "0.7.0-alpha.3" } diff --git a/packages/cli-telemetry/Cargo.toml b/packages/cli-telemetry/Cargo.toml new file mode 100644 index 000000000..8757c4106 --- /dev/null +++ b/packages/cli-telemetry/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "dioxus-cli-telemetry" +edition = "2021" +version.workspace = true +description = "Wire format for the dioxus CLI telemetry type" + +[dependencies] +serde = { workspace = true, features = ["derive"] } +chrono = { workspace = true, features = ["serde"] } +rand = { workspace = true } +serde_json = { workspace = true } +dirs = { workspace = true } +uuid = { workspace = true, features = ["serde", "v4"] } diff --git a/packages/cli-telemetry/src/lib.rs b/packages/cli-telemetry/src/lib.rs new file mode 100644 index 000000000..6d71bc127 --- /dev/null +++ b/packages/cli-telemetry/src/lib.rs @@ -0,0 +1,272 @@ +//! # Telemetry for the Dioxus CLI +//! +//! Dioxus uses telemetry in the CLI to get insight into metrics like performance, panics, and usage +//! of various arguments. This data helps us track down bugs and improve quality of the tooling. +//! +//! Usage of telemetry in open source products can be controversial. Our goal here is to collect +//! minimally invasive data used exclusively to improve our tooling. Github issues only show *some* +//! of the problem, but many users stumble into issues which go unreported. +//! +//! Our policy follows: +//! - minimally invasive +//! - anonymous +//! - periodic +//! - transparent +//! - easy to disable +//! +//! We send a heartbeat when the CLI is executed and then rollups of logs over time. +//! - Heartbeat: helps us track version distribution of the CLI and critical "failures on launch" useful during new version rollouts. +//! - Rollups: helps us track performance and issues over time, as well as usage of various commands. +//! +//! If you don't run the CLI, then we won't send any data. Rollups are not done in background processes. +//! +//! We don't collect any PII, but we do collect three "controversial" pieces of data: +//! - the target triple of your system (OS, arch, etc) +//! - a session ID which is a random number generated on each run +//! - a distinct ID per `.dx` installation which is a random number generated on initial run. +//! +//! The distinct ID is used to track the same installation over time, but it is not tied to any user +//! account or PII. Since `dx` doesn't have any accounts or authentication mechanism, this ID is used +//! as a "best effort" identifier. If you still want to participate in telemetry but don't want a +//! distinct ID, you can replace the stable_id.json file in the `.dx` directory with an empty string. +//! +//! In the CLI, you can disable this by using any of the methods: +//! - installing with the "disable-telemetry" feature flag +//! - setting TELEMETRY=false in your env +//! - setting `dx config set disable-telemetry true` + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashMap}, + time::SystemTime, +}; + +/// An event's data, corresponding roughly to data collected from an individual trace. +/// +/// This can be something like a build, bundle, translate, etc +/// We collect the phases of the build in a list of events to get a better sense of how long +/// it took. +/// +/// Note that this is just the data and does not include the reporter information. +/// +/// On the analytics, side, we reconstruct the trace messages into a sequence of events, using +/// the stage as a marker. +/// +/// If the event contains a stack trace, it is considered a crash event and will be sent to the crash reporting service. +/// +/// We store this type on disk without the reporter information or any information about the CLI. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TelemetryEventData { + /// The name of the command that was run, e.g. "dx build", "dx bundle", "dx serve" + pub command: String, + + /// The action that was taken, e.g. "build", "bundle", "cli_invoked", "cli_crashed" etc + pub action: String, + + /// An additional message to include in the event, e.g. "start", "end", "error", etc + pub message: String, + + /// The "name" of the error. In our case, usually" "RustError" or "RustPanic". In other languages + /// this might be the exception type. In Rust, this is usually the name of the error type. (e.g. "std::io::Error", etc) + pub error_type: Option, + + /// Whether the event was handled or not. Unhandled errors are the default, but some we recover from (like hotpatching issues). + pub error_handled: bool, + + /// Additional values to include in the event, e.g. "duration", "enabled", etc. + pub values: HashMap, + + /// Timestamp of the event, in UTC, derived from the user's system time. Might not be reliable. + pub time: DateTime, + + /// The module where the event occurred, stripped of paths for privacy. + pub module: Option, + + /// The file or module where the event occurred, stripped of paths for privacy, relative to the monorepo root. + pub file: Option, + + /// The line and column where the event occurred, if applicable. + pub line: Option, + + /// The column where the event occurred, if applicable. + pub column: Option, + + /// The stack frames of the event, if applicable. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub stack_frames: Vec, +} + +impl TelemetryEventData { + pub fn new(name: impl ToString, message: impl ToString) -> Self { + Self { + command: std::env::args() + .nth(1) + .unwrap_or_else(|| "unknown".to_string()), + action: strip_paths(&name.to_string()), + message: strip_paths(&message.to_string()), + file: None, + module: None, + time: DateTime::::from(SystemTime::now()), + values: HashMap::new(), + error_type: None, + column: None, + line: None, + stack_frames: vec![], + error_handled: false, + } + } + + pub fn with_value(mut self, key: K, value: V) -> Self { + let mut value = serde_json::to_value(value).unwrap(); + strip_paths_value(&mut value); + self.values.insert(key.to_string(), value); + self + } + + pub fn with_module(mut self, module: impl ToString) -> Self { + self.module = Some(strip_paths(&module.to_string())); + self + } + + pub fn with_file(mut self, file: impl ToString) -> Self { + self.file = Some(strip_paths(&file.to_string())); + self + } + + pub fn with_line_column(mut self, line: u32, column: u32) -> Self { + self.line = Some(line); + self.column = Some(column); + self + } + + pub fn with_error_handled(mut self, error_handled: bool) -> Self { + self.error_handled = error_handled; + self + } + + pub fn with_error_type(mut self, error_type: String) -> Self { + self.error_type = Some(error_type); + self + } + + pub fn with_stack_frames(mut self, stack_frames: Vec) -> Self { + self.stack_frames = stack_frames; + self + } + + pub fn with_values(mut self, fields: serde_json::Map) -> Self { + for (key, value) in fields { + self = self.with_value(key, value); + } + self + } + + pub fn to_json(&self) -> serde_json::Value { + serde_json::to_value(self).unwrap() + } +} + +/// Display implementation for TelemetryEventData, such that you can use it in tracing macros with the "%" syntax. +impl std::fmt::Display for TelemetryEventData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", serde_json::to_string(self).unwrap()) + } +} + +/// A serialized stack frame, in a format that matches PostHog's stack frame format. +/// +/// Read more: +/// +/// +/// Supposedly, this is compatible with Sentry's stack frames as well. In the CLI we use sentry-backtrace +/// even though we don't actually use sentry. +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "snake_case")] +pub struct StackFrame { + pub raw_id: String, + + pub mangled_name: String, + + pub resolved_name: String, + + pub lang: String, + + pub resolved: bool, + + pub platform: String, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub filename: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub function: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub module: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lineno: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub colno: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub abs_path: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_line: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pre_context: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub post_context: Vec, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub in_app: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instruction_addr: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub addr_mode: Option, + + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub vars: BTreeMap, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chunk_id: Option, +} + +// If the CLI is compiled locally, it can contain backtraces which contain the home path with the username in it. +pub fn strip_paths(string: &str) -> String { + // Strip the home path from any paths in the backtrace + let home_dir = dirs::home_dir().unwrap_or_default(); + + // Strip every path between the current path and the home directory + let mut cwd = std::env::current_dir().unwrap_or_default(); + let mut string = string.to_string(); + loop { + string = string.replace(&*cwd.to_string_lossy(), ""); + let Some(parent) = cwd.parent() else { + break; + }; + cwd = parent.to_path_buf(); + if cwd == home_dir { + break; + } + } + + // Finally, strip the home directory itself (in case the cwd is outside the home directory) + string.replace(&*home_dir.to_string_lossy(), "~") +} + +fn strip_paths_value(value: &mut serde_json::Value) { + match value { + serde_json::Value::String(s) => *s = strip_paths(s), + serde_json::Value::Object(map) => map.values_mut().for_each(strip_paths_value), + serde_json::Value::Array(arr) => arr.iter_mut().for_each(strip_paths_value), + _ => {} + } +} diff --git a/packages/cli/Cargo.toml b/packages/cli/Cargo.toml index f1f930c6e..3363498be 100644 --- a/packages/cli/Cargo.toml +++ b/packages/cli/Cargo.toml @@ -26,6 +26,7 @@ dioxus-dx-wire-format = { workspace = true } wasm-split-cli = { workspace = true } depinfo = { workspace = true } subsecond-types = { workspace = true } +dioxus-cli-telemetry = { workspace = true } clap = { workspace = true, features = ["derive", "cargo"] } convert_case = { workspace = true } @@ -154,6 +155,10 @@ self_update = { version = "0.42.0", features = [ ] } self-replace = "1.5.0" cargo-config2 = { workspace = true } +posthog-rs = "0.3.5" +serde_json5 = "0.2.1" +fs2 = "0.4.3" +sentry-backtrace = "0.42.0" [build-dependencies] built = { version = "0.8.0", features = ["git2"] } @@ -162,6 +167,7 @@ built = { version = "0.8.0", features = ["git2"] } default = [] tokio-console = ["dep:console-subscriber", "tokio/tracing"] no-downloads = [] +disable-telemetry = [] [[bin]] path = "src/main.rs" diff --git a/packages/cli/src/build/builder.rs b/packages/cli/src/build/builder.rs index 250176f95..f545794a3 100644 --- a/packages/cli/src/build/builder.rs +++ b/packages/cli/src/build/builder.rs @@ -424,7 +424,6 @@ impl AppBuilder { } } - tracing::error!(?err, json = ?StructuredOutput::Error { message: err.to_string() }); return Err(err); } BuilderUpdate::StdoutReceived { .. } => {} diff --git a/packages/cli/src/build/context.rs b/packages/cli/src/build/context.rs index c0ab88edc..30a491352 100644 --- a/packages/cli/src/build/context.rs +++ b/packages/cli/src/build/context.rs @@ -38,10 +38,13 @@ pub enum BuilderUpdate { message: Diagnostic, }, + /// The build completed successfully and the artifacts are ready. The artifacts are dependent on + /// the build mode (fat vs thin vs base). BuildReady { bundle: BuildArtifacts, }, + /// The build failed. This might be because of a compilation error, or an error internal to DX. BuildFailed { err: Error, }, @@ -62,6 +65,7 @@ pub enum BuilderUpdate { msg: String, }, + /// The running app (DUT) has exited and is no longer running. ProcessExited { status: ExitStatus, }, diff --git a/packages/cli/src/build/request.rs b/packages/cli/src/build/request.rs index 53b3a96c9..a6c5ace20 100644 --- a/packages/cli/src/build/request.rs +++ b/packages/cli/src/build/request.rs @@ -1943,7 +1943,7 @@ impl BuildRequest { } } LinkerFlavor::Unsupported => { - tracing::error!("Unsupported platform for fat linking"); + tracing::error!("Unsupported platform for fat linking: {}", self.triple); } }; } diff --git a/packages/cli/src/cli/build.rs b/packages/cli/src/cli/build.rs index 096426601..54ec7b869 100644 --- a/packages/cli/src/cli/build.rs +++ b/packages/cli/src/cli/build.rs @@ -1,7 +1,4 @@ -use crate::BuildMode; -use crate::{cli::*, AppBuilder, BuildRequest, Workspace}; - -use super::target::TargetArgs; +use crate::{cli::*, Anonymized, AppBuilder, BuildMode, BuildRequest, TargetArgs, Workspace}; /// Build the Rust Dioxus app and all of its assets. /// @@ -28,6 +25,16 @@ pub struct BuildArgs { pub(crate) build_arguments: TargetArgs, } +impl Anonymized for BuildArgs { + fn anonymized(&self) -> Value { + json! {{ + "fullstack": self.fullstack, + "ssg": self.ssg, + "build_arguments": self.build_arguments.anonymized(), + }} + } +} + pub struct BuildTargets { pub client: BuildRequest, pub server: Option, diff --git a/packages/cli/src/cli/config.rs b/packages/cli/src/cli/config.rs index d07f2f6a3..7a51f9a84 100644 --- a/packages/cli/src/cli/config.rs +++ b/packages/cli/src/cli/config.rs @@ -21,9 +21,6 @@ pub(crate) enum Config { /// Create a custom html file. CustomHtml {}, - /// Print the location of the CLI log file. - LogFile {}, - /// Set CLI settings. #[command(subcommand)] Set(Setting), @@ -39,6 +36,8 @@ pub(crate) enum Setting { AlwaysOnTop { value: BoolValue }, /// Set the interval that file changes are polled on WSL for hot reloading. WSLFilePollInterval { value: u16 }, + /// Disable the built-in telemetry for the CLI + DisableTelemetry { value: BoolValue }, } impl Display for Setting { @@ -48,13 +47,14 @@ impl Display for Setting { Self::AlwaysOpenBrowser { value: _ } => write!(f, "always-open-browser"), Self::AlwaysOnTop { value: _ } => write!(f, "always-on-top"), Self::WSLFilePollInterval { value: _ } => write!(f, "wsl-file-poll-interval"), + Self::DisableTelemetry { value: _ } => write!(f, "disable-telemetry"), } } } // Clap complains if we use a bool directly and I can't find much info about it. // "Argument 'value` is positional and it must take a value but action is SetTrue" -#[derive(Debug, Clone, Copy, Deserialize, clap::ValueEnum)] +#[derive(Debug, Clone, Copy, serde::Serialize, Deserialize, clap::ValueEnum)] pub(crate) enum BoolValue { True, False, @@ -98,10 +98,6 @@ impl Config { file.write_all(content.as_bytes())?; tracing::info!(dx_src = ?TraceSrc::Dev, "🚩 Create custom html file done."); } - Config::LogFile {} => { - let log_path = crate::logging::FileAppendLayer::log_path(); - tracing::info!(dx_src = ?TraceSrc::Dev, "Log file is located at {}", log_path.display()); - } // Handle CLI settings. Config::Set(setting) => { CliSettings::modify_settings(|settings| match setting { @@ -115,6 +111,9 @@ impl Config { Setting::WSLFilePollInterval { value } => { settings.wsl_file_poll_interval = Some(value) } + Setting::DisableTelemetry { value } => { + settings.disable_telemetry = Some(value.into()); + } })?; tracing::info!(dx_src = ?TraceSrc::Dev, "🚩 CLI setting `{setting}` has been set."); } diff --git a/packages/cli/src/cli/create.rs b/packages/cli/src/cli/create.rs index 7da8bd23e..3d0f22c71 100644 --- a/packages/cli/src/cli/create.rs +++ b/packages/cli/src/cli/create.rs @@ -10,48 +10,48 @@ pub(crate) static DEFAULT_TEMPLATE: &str = "gh:dioxuslabs/dioxus-template"; #[clap(name = "new")] pub struct Create { /// Create a new Dioxus project at PATH - path: PathBuf, + pub path: PathBuf, /// Project name. Defaults to directory name #[arg(short, long)] - name: Option, + pub name: Option, /// Template path #[clap(short, long)] - template: Option, + pub template: Option, /// Branch to select when using `template` from a git repository. /// Mutually exclusive with: `--revision`, `--tag`. #[clap(long, conflicts_with_all(["revision", "tag"]))] - branch: Option, + pub branch: Option, /// A commit hash to select when using `template` from a git repository. /// Mutually exclusive with: `--branch`, `--tag`. #[clap(long, conflicts_with_all(["branch", "tag"]))] - revision: Option, + pub revision: Option, /// Tag to select when using `template` from a git repository. /// Mutually exclusive with: `--branch`, `--revision`. #[clap(long, conflicts_with_all(["branch", "revision"]))] - tag: Option, + pub tag: Option, /// Specify a sub-template within the template repository to be used as the actual template #[clap(long)] - subtemplate: Option, + pub subtemplate: Option, /// Pass `