Add config setting for S3 and Salesforce
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
max_width = 120
|
||||
hard_tabs = true
|
||||
+79
-137
@@ -3,6 +3,8 @@ use anyhow::{Context, Result};
|
||||
use clap::{Parser, ValueEnum};
|
||||
use log::error;
|
||||
|
||||
mod set;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
struct Cli {
|
||||
@@ -33,6 +35,31 @@ enum Commands {
|
||||
|
||||
/// Change settings
|
||||
Set {
|
||||
/// A comma separated list of fallback email address,
|
||||
/// used when Salesforce cannot be reached to deliver log messages
|
||||
#[arg(short = 'a', long, env = "IMOJEFS_ADMIN_EMAIL")]
|
||||
admin_email: Option<String>,
|
||||
|
||||
/// AWS S3 bucket name
|
||||
#[arg(short = 'b', long = "s3-bucket", env = "IMOJEFS_S3_BUCKET")]
|
||||
s3_bucket: Option<String>,
|
||||
|
||||
/// AWS Access Key Id
|
||||
#[arg(short = 'k', long, env = "IMOJEFS_S3_CLIENT_ID")]
|
||||
s3_client_id: Option<String>,
|
||||
|
||||
/// AWS Secret Access Key
|
||||
#[arg(short = 's', long, env = "IMOJEFS_S3_CLIENT_SECRET", hide_env_values = true)]
|
||||
s3_client_secret: Option<String>,
|
||||
|
||||
/// AWS Endpoint URL, [default: AWS url]
|
||||
#[arg(short = 'e', long, env = "IMOJEFS_S3_ENDPOINT")]
|
||||
s3_endpoint: Option<String>,
|
||||
|
||||
/// AWS Region
|
||||
#[arg(short = 'r', long, default_value = "us-east-1", env = "IMOJEFS_S3_REGION")]
|
||||
s3_region: Option<String>,
|
||||
|
||||
/// Salesforce Authentication Url
|
||||
#[arg(
|
||||
short = 'x',
|
||||
@@ -44,76 +71,6 @@ enum Commands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Args {
|
||||
/// AWS Bucket name
|
||||
#[arg(short = 'b', long, env = "IMOJEFS_S3_BUCKET")]
|
||||
pub s3_bucket: String,
|
||||
|
||||
/// AWS Access Key Id
|
||||
#[arg(short = 'k', long, env = "IMOJEFS_S3_CLIENT_ID")]
|
||||
pub s3_client_id: String,
|
||||
|
||||
/// AWS Secret Access Key
|
||||
#[arg(
|
||||
short = 's',
|
||||
long,
|
||||
env = "IMOJEFS_S3_CLIENT_SECRET",
|
||||
hide_env_values = true
|
||||
)]
|
||||
pub s3_client_secret: String,
|
||||
|
||||
/// AWS Endpoint URL, [default: AWS url]
|
||||
#[arg(short = 'e', long, env = "IMOJEFS_S3_ENDPOINT")]
|
||||
pub s3_endpoint: Option<String>,
|
||||
|
||||
/// AWS Region
|
||||
#[arg(
|
||||
short = 'r',
|
||||
long,
|
||||
default_value = "us-east-1",
|
||||
env = "IMOJEFS_S3_REGION"
|
||||
)]
|
||||
pub s3_region: String,
|
||||
|
||||
/// Salesforce Authentication Url
|
||||
#[arg(
|
||||
short = 'x',
|
||||
long,
|
||||
env = "IMOJEFS_SFDX_AUTH_URL",
|
||||
hide_env_values = true
|
||||
)]
|
||||
pub sfdx_auth_url: String,
|
||||
|
||||
/// Retry failures after X runs
|
||||
#[arg(long, default_value_t = 180, env = "IMOJEFS_RETRY")]
|
||||
pub retry: u64,
|
||||
|
||||
/// A comma separated list of fallback email address,
|
||||
/// used when Salesforce cannot be reached to deliver log messages
|
||||
#[arg(short = 'a', long, env = "IMOJEFS_ADMIN_EMAIL")]
|
||||
pub admin_email: Option<String>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
pub(crate) fn s3_bucket(&self) -> &str {
|
||||
&self.s3_bucket
|
||||
}
|
||||
pub(crate) fn s3_client_id(&self) -> &str {
|
||||
&self.s3_client_id
|
||||
}
|
||||
pub(crate) fn s3_client_secret(&self) -> &str {
|
||||
&self.s3_client_secret
|
||||
}
|
||||
pub(crate) fn s3_endpoint(&self) -> Option<&str> {
|
||||
self.s3_endpoint.as_deref()
|
||||
}
|
||||
pub(crate) fn s3_region(&self) -> &str {
|
||||
&self.s3_region
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents the different levels of logging that can be used in an application.
|
||||
///
|
||||
/// # Variants
|
||||
@@ -133,58 +90,31 @@ pub enum LogLevel {
|
||||
Trace,
|
||||
}
|
||||
|
||||
fn load_configuration_file(config_file: &str) -> Result<()> {
|
||||
let app_config =
|
||||
crate::config::load(config_file).context("Failed to load application configuration")?;
|
||||
crate::APP_CONFIG
|
||||
.set(app_config)
|
||||
.map_err(|_| anyhow::anyhow!("Application configuration has already been initialized"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
fn list_config_settings(config_file: &str) -> Result<()> {
|
||||
let app_config =
|
||||
crate::config::load(config_file).context("Failed to load application configuration")?;
|
||||
|
||||
println!("Configuration: {config_file}");
|
||||
println!(
|
||||
"sfdx_auth_url = {}",
|
||||
app_config
|
||||
.sfdx_auth_url
|
||||
.as_deref()
|
||||
.unwrap_or("<not set>")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_sfdx_auth_url(config_file: &str, url: &str) -> Result<()> {
|
||||
let mut app_config =
|
||||
crate::config::load(config_file).context("Failed to load application configuration")?;
|
||||
|
||||
app_config.sfdx_auth_url = Some(url.to_string());
|
||||
|
||||
crate::config::save(config_file, &app_config)
|
||||
.context("Failed to save Salesforce authentication URL")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn run() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match &cli.command {
|
||||
Some(command) => match command {
|
||||
Commands::Set { sfdx_auth_url } => {
|
||||
run_set_command(&cli, sfdx_auth_url);
|
||||
},
|
||||
Commands::List => {
|
||||
run_list_command(&cli);
|
||||
},
|
||||
Commands::FileSync { .. } => {
|
||||
run_file_sync(&cli);
|
||||
}
|
||||
Commands::Set {
|
||||
admin_email,
|
||||
s3_bucket,
|
||||
s3_client_id,
|
||||
s3_client_secret,
|
||||
s3_endpoint,
|
||||
s3_region,
|
||||
sfdx_auth_url,
|
||||
} => run_set_command(
|
||||
&cli.config,
|
||||
admin_email,
|
||||
s3_bucket,
|
||||
s3_client_id,
|
||||
s3_client_secret,
|
||||
s3_endpoint,
|
||||
s3_region,
|
||||
sfdx_auth_url,
|
||||
),
|
||||
Commands::List => run_list_command(&cli),
|
||||
Commands::FileSync { .. } => run_file_sync(&cli),
|
||||
},
|
||||
None => {
|
||||
println!("Expected subcommand, none given. Use --help for more information");
|
||||
@@ -193,6 +123,14 @@ pub(crate) fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_configuration_file(config_file: &str) -> Result<()> {
|
||||
let app_config = crate::config::load(config_file).context("Failed to load application configuration")?;
|
||||
crate::APP_CONFIG
|
||||
.set(app_config)
|
||||
.map_err(|_| anyhow::anyhow!("Application configuration has already been initialized"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_file_sync(cli: &Cli) {
|
||||
logger::init(&cli.log_level);
|
||||
|
||||
@@ -208,7 +146,8 @@ fn run_file_sync(cli: &Cli) {
|
||||
}
|
||||
|
||||
fn run_list_command(cli: &Cli) {
|
||||
match list_config_settings(&cli.config) {
|
||||
match crate::config::list(&cli.config)
|
||||
.context("Failed to load application configuration") {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
error!("Error listing configuration settings: {:#}", e);
|
||||
@@ -217,29 +156,32 @@ fn run_list_command(cli: &Cli) {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_set_command(cli: &Cli, sfdx_auth_url: &Option<String>) {
|
||||
let mut updated = false;
|
||||
|
||||
updated = run_set_sfdx_auth_url(cli, sfdx_auth_url, &updated);
|
||||
|
||||
if !updated {
|
||||
println!("Expected at least one setting flag. Use --help for more information");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_set_sfdx_auth_url(cli: &Cli, sfdx_auth_url: &Option<String>, updated: &bool) -> bool {
|
||||
if let Some(url) = sfdx_auth_url {
|
||||
match set_sfdx_auth_url(&cli.config, url) {
|
||||
fn run_set_command(
|
||||
config_file: &str,
|
||||
admin_email: &Option<String>,
|
||||
s3_bucket: &Option<String>,
|
||||
s3_client_id: &Option<String>,
|
||||
s3_client_secret: &Option<String>,
|
||||
s3_endpoint: &Option<String>,
|
||||
s3_region: &Option<String>,
|
||||
sfdx_auth_url: &Option<String>,
|
||||
) {
|
||||
match set::run(
|
||||
config_file,
|
||||
admin_email,
|
||||
s3_bucket,
|
||||
s3_client_id,
|
||||
s3_client_secret,
|
||||
s3_endpoint,
|
||||
s3_region,
|
||||
sfdx_auth_url,
|
||||
) {
|
||||
Ok(()) => {
|
||||
println!("Salesforce authentication URL saved successfully");
|
||||
return true;
|
||||
println!("Configuration settings updated successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Error saving Salesforce authentication URL: {:#}", e);
|
||||
error!("Error setting configuration: {:#}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
*updated
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
pub(crate) fn run(config_file: &str,
|
||||
admin_email: &Option<String>,
|
||||
s3_bucket: &Option<String>,
|
||||
s3_client_id: &Option<String>,
|
||||
s3_client_secret: &Option<String>,
|
||||
s3_endpoint: &Option<String>,
|
||||
s3_region: &Option<String>,
|
||||
sfdx_auth_url: &Option<String>,) -> Result<()> {
|
||||
|
||||
let mut updated = false;
|
||||
|
||||
let mut app_config = crate::config::load(config_file)
|
||||
.context("Failed to load application configuration")?;
|
||||
|
||||
|
||||
if let Some(admin_email) = admin_email {
|
||||
app_config.admin_email = Some(admin_email.to_string());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if let Some(bucket) = s3_bucket {
|
||||
app_config.s3_bucket = Some(bucket.to_string());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if let Some(s3_client_id) = s3_client_id {
|
||||
app_config.s3_client_id = Some(s3_client_id.to_string());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if let Some(s3_client_secret) = s3_client_secret {
|
||||
app_config.s3_client_secret = Some(s3_client_secret.to_string());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if let Some(s3_endpoint) = s3_endpoint {
|
||||
app_config.s3_endpoint = Some(s3_endpoint.to_string());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if let Some(s3_region) = s3_region {
|
||||
app_config.s3_region = Some(s3_region.to_string());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if let Some(url) = sfdx_auth_url {
|
||||
app_config.sfdx_auth_url = Some(url.to_string());
|
||||
updated = true;
|
||||
}
|
||||
|
||||
crate::config::save(config_file, &app_config)
|
||||
.context("Failed to save configuration")?;
|
||||
|
||||
if !updated {
|
||||
println!("Expected at least one settings flag. Use --help for more information");
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+251
@@ -13,19 +13,79 @@ const CONFIG_KEY_ENV: &str = "IMOJE_CONFIG_KEY";
|
||||
const CONFIG_DIR: &str = "config/imoje";
|
||||
const NONCE_LEN: usize = 12;
|
||||
|
||||
const NOT_SET: &'static str = "<not set>";
|
||||
const SECRET_HIDDEN: &'static str = "***** secret hidden *****";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub admin_email: Option<String>,
|
||||
pub s3_bucket: Option<String>,
|
||||
pub s3_client_id: Option<String>,
|
||||
pub s3_client_secret: Option<String>,
|
||||
pub s3_endpoint: Option<String>,
|
||||
pub s3_region: Option<String>,
|
||||
pub sfdx_auth_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
admin_email: None,
|
||||
s3_bucket: None,
|
||||
s3_client_id: None,
|
||||
s3_client_secret: None,
|
||||
s3_endpoint: None,
|
||||
s3_region: None,
|
||||
sfdx_auth_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list(config_file: &str) -> Result<()> {
|
||||
let app_config = load(config_file).context("Failed to load application configuration")?;
|
||||
|
||||
println!("Configuration: {}", config_file);
|
||||
println!("----------------------------------------------------");
|
||||
println!(
|
||||
"admin_email = {}",
|
||||
app_config.admin_email.as_deref().unwrap_or(NOT_SET)
|
||||
);
|
||||
println!(
|
||||
"s3_bucket = {}",
|
||||
app_config.s3_bucket.as_deref().unwrap_or(NOT_SET)
|
||||
);
|
||||
println!(
|
||||
"s3_client_id = {}",
|
||||
app_config.s3_client_id.as_deref().unwrap_or(NOT_SET)
|
||||
);
|
||||
println!(
|
||||
"s3_client_secret = {}",
|
||||
match app_config.s3_client_secret {
|
||||
Some(_) => SECRET_HIDDEN,
|
||||
None => NOT_SET,
|
||||
}
|
||||
);
|
||||
println!(
|
||||
"s3_endpoint = {}",
|
||||
app_config.s3_endpoint.as_deref().unwrap_or(NOT_SET)
|
||||
);
|
||||
|
||||
println!(
|
||||
"s3_region = {}",
|
||||
app_config.s3_region.as_deref().unwrap_or(NOT_SET)
|
||||
);
|
||||
println!(
|
||||
"sfdx_auth_url = {}",
|
||||
match app_config.sfdx_auth_url {
|
||||
Some(_) => SECRET_HIDDEN,
|
||||
None => NOT_SET,
|
||||
}
|
||||
);
|
||||
|
||||
Ok(())
|
||||
|
||||
}
|
||||
|
||||
pub fn load(config_file: &str) -> Result<AppConfig> {
|
||||
let config_path = config_path(config_file)?;
|
||||
|
||||
@@ -131,3 +191,194 @@ fn config_key() -> Result<[u8; 32]> {
|
||||
key.try_into()
|
||||
.map_err(|_| anyhow!("{CONFIG_KEY_ENV} must decode to exactly 32 bytes"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
const TEST_KEY: &str = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=";
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("environment lock should not be poisoned")
|
||||
}
|
||||
|
||||
fn unique_temp_home() -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"imojefs-config-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system time should be after UNIX_EPOCH")
|
||||
.as_nanos()
|
||||
));
|
||||
path
|
||||
}
|
||||
|
||||
fn set_test_key() {
|
||||
unsafe {
|
||||
std::env::set_var(CONFIG_KEY_ENV, TEST_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_home_dir(path: &Path) {
|
||||
unsafe {
|
||||
std::env::set_var("HOME", path);
|
||||
std::env::set_var("USERPROFILE", path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_config_default_has_no_values() {
|
||||
let config = AppConfig::default();
|
||||
|
||||
assert_eq!(config.sfdx_auth_url, None);
|
||||
assert_eq!(config.s3_bucket, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_key_accepts_base64_encoded_32_byte_key() {
|
||||
let _lock = env_lock();
|
||||
set_test_key();
|
||||
|
||||
let key = config_key().expect("test key should be valid");
|
||||
|
||||
assert_eq!(key, *b"0123456789abcdef0123456789abcdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_key_rejects_invalid_base64() {
|
||||
let _lock = env_lock();
|
||||
unsafe {
|
||||
std::env::set_var(CONFIG_KEY_ENV, "not valid base64");
|
||||
}
|
||||
|
||||
let error = config_key().expect_err("invalid base64 should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("IMOJE_CONFIG_KEY must be a base64-encoded 32-byte key")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_key_rejects_wrong_key_length() {
|
||||
let _lock = env_lock();
|
||||
unsafe {
|
||||
std::env::set_var(CONFIG_KEY_ENV, BASE64.encode(b"too short"));
|
||||
}
|
||||
|
||||
let error = config_key().expect_err("short key should fail");
|
||||
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("IMOJE_CONFIG_KEY must decode to exactly 32 bytes")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypt_config_produces_base64_payload_with_nonce_and_ciphertext() {
|
||||
let _lock = env_lock();
|
||||
set_test_key();
|
||||
|
||||
let encrypted = encrypt_config("sfdx_auth_url = 'auth-url'")
|
||||
.expect("configuration should encrypt");
|
||||
let encrypted_payload = BASE64
|
||||
.decode(encrypted)
|
||||
.expect("encrypted configuration should be base64");
|
||||
|
||||
assert!(encrypted_payload.len() > NONCE_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_config_round_trips_encrypted_configuration() {
|
||||
let _lock = env_lock();
|
||||
set_test_key();
|
||||
|
||||
let encrypted = encrypt_config(
|
||||
r#"
|
||||
sfdx_auth_url = "force://example"
|
||||
s3_bucket = "example-bucket"
|
||||
"#,
|
||||
)
|
||||
.expect("configuration should encrypt");
|
||||
|
||||
let decrypted = decrypt_config(&encrypted).expect("configuration should decrypt");
|
||||
|
||||
assert_eq!(decrypted.sfdx_auth_url.as_deref(), Some("force://example"));
|
||||
assert_eq!(decrypted.s3_bucket.as_deref(), Some("example-bucket"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_config_rejects_non_base64_input() {
|
||||
let _lock = env_lock();
|
||||
set_test_key();
|
||||
|
||||
let error = decrypt_config("not valid base64").expect_err("invalid base64 should fail");
|
||||
|
||||
assert!(error.to_string().contains("Configuration file is not valid base64"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decrypt_config_rejects_payload_without_ciphertext() {
|
||||
let _lock = env_lock();
|
||||
set_test_key();
|
||||
let encrypted = BASE64.encode([0_u8; NONCE_LEN]);
|
||||
|
||||
let error = decrypt_config(&encrypted).expect_err("short payload should fail");
|
||||
|
||||
assert!(error.to_string().contains("Configuration file is too short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_round_trip_configuration_file() {
|
||||
let _lock = env_lock();
|
||||
set_test_key();
|
||||
let temp_home = unique_temp_home();
|
||||
set_home_dir(&temp_home);
|
||||
|
||||
let config = AppConfig {
|
||||
admin_email: None,
|
||||
sfdx_auth_url: Some("force://saved".to_string()),
|
||||
s3_bucket: Some("saved-bucket".to_string()),
|
||||
s3_client_id: None,
|
||||
s3_client_secret: None,
|
||||
s3_endpoint: None,
|
||||
s3_region: None,
|
||||
};
|
||||
|
||||
save("round-trip", &config).expect("configuration should save");
|
||||
let loaded = load("round-trip").expect("configuration should load");
|
||||
|
||||
assert_eq!(loaded.sfdx_auth_url, config.sfdx_auth_url);
|
||||
assert_eq!(loaded.s3_bucket, config.s3_bucket);
|
||||
|
||||
fs::remove_dir_all(temp_home).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_creates_default_configuration_when_file_is_missing() {
|
||||
let _lock = env_lock();
|
||||
set_test_key();
|
||||
let temp_home = unique_temp_home();
|
||||
set_home_dir(&temp_home);
|
||||
|
||||
let loaded = load("missing").expect("default configuration should load");
|
||||
let expected_path = temp_home.join(CONFIG_DIR).join("missing.conf");
|
||||
|
||||
assert_eq!(loaded.sfdx_auth_url, None);
|
||||
assert_eq!(loaded.s3_bucket, None);
|
||||
assert!(expected_path.exists());
|
||||
|
||||
fs::remove_dir_all(temp_home).ok();
|
||||
}
|
||||
}
|
||||
+5
-6
@@ -1,9 +1,9 @@
|
||||
//! # IMoje File Sync
|
||||
//! # IMoje CLI
|
||||
//!
|
||||
//! Syncs the contents of a Salesforce File to S3
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
//! CLI features;
|
||||
//! - **file-sync** Syncs the contents of a Salesforce File to S3
|
||||
//! - **set** Sets the value of a configuration
|
||||
//! - **list** Lists the contents of a configuration file`
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -19,7 +19,6 @@ mod file_sync;
|
||||
|
||||
pub static APP_CONFIG: OnceLock<config::AppConfig> = OnceLock::new();
|
||||
|
||||
|
||||
#[::tokio::main]
|
||||
async fn main() {
|
||||
cli::run();
|
||||
|
||||
Reference in New Issue
Block a user