diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 220e5e2..a662423 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -17,5 +17,20 @@ serde_json.workspace = true thiserror.workspace = true time.workspace = true time-humanize.workspace = true +toml.workspace = true tracing.workspace = true tracing-subscriber.workspace = true + +config = { version = "0.15", default-features = false, features = ["toml"] } +fs-err = { version = "3.1" } +indicatif = { version = "0.18" } +shellexpand = { version = "3.1" } + +[dependencies.reqwest] +version = "0.12" +default-features = false +features = ["json", "rustls-tls-native-roots"] + +[profile.dist] +inherits = "release" +lto = "thin" diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs new file mode 100644 index 0000000..e1c8ad7 --- /dev/null +++ b/crates/cli/src/args.rs @@ -0,0 +1,61 @@ +use std::path::PathBuf; + +use clap::{builder::styling, Parser, Subcommand}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +static HELP_TEMPLATE: &str = "\ +{before-help}{name} {version} +{about} + +{usage-heading} + {usage} + +{all-args}{after-help}"; + +pub mod host; +pub mod teams; +pub mod uploads; +pub mod download; +pub mod upload; + +const STYLES: styling::Styles = styling::Styles::styled() + .header(styling::AnsiColor::Green.on_default().bold()) + .usage(styling::AnsiColor::Green.on_default().bold()) + .literal(styling::AnsiColor::Blue.on_default().bold()) + .placeholder(styling::AnsiColor::Cyan.on_default()); + +/// CLI for Parcel file sharing service +#[derive(Debug, Parser)] +#[command( + name = "parcel", + version = VERSION, + help_template = HELP_TEMPLATE, + styles = STYLES, +)] +pub struct Args { + /// The location of the Parcel configuration file + #[arg(long)] + pub config: Option, + + /// The command to run + #[command(subcommand)] + command: ParcelCommand, +} + +#[derive(Debug, Subcommand)] +pub enum ParcelCommand { + /// Manage Parcel hosts + #[command(subcommand)] + Host(host::HostCommand), + /// Access Parcel teams + #[command(subcommand)] + Teams(teams::TeamsCommand), + /// Manage uploads on Parcel + #[command(subcommand)] + Uploads(uploads::UploadsCommand), + /// Download from Parcel + Download(download::DownloadCommand), + /// Upload to Parcel + Upload(upload::UploadCommand), +} diff --git a/crates/cli/src/args/download.rs b/crates/cli/src/args/download.rs new file mode 100644 index 0000000..52fc5d0 --- /dev/null +++ b/crates/cli/src/args/download.rs @@ -0,0 +1,4 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct DownloadCommand {} diff --git a/crates/cli/src/args/host.rs b/crates/cli/src/args/host.rs new file mode 100644 index 0000000..1d2c8ae --- /dev/null +++ b/crates/cli/src/args/host.rs @@ -0,0 +1,23 @@ +use clap::Subcommand; + +pub mod add; +pub mod check; +pub mod list; +pub mod remove; +pub mod set; + +#[derive(Debug, Subcommand)] +pub enum HostCommand { + /// Add a new host + Add(add::AddHostCommand), + /// Remove a host + Remove(remove::RemoveHostCommand), + /// List all hosts + List(list::ListHostsCommand), + /// Check all hosts + CheckAll, + /// Check a specific host + Check(check::CheckHostCommand), + /// Change host settings + Set(set::SetHostCommand), +} diff --git a/crates/cli/src/args/host/add.rs b/crates/cli/src/args/host/add.rs new file mode 100644 index 0000000..26d9403 --- /dev/null +++ b/crates/cli/src/args/host/add.rs @@ -0,0 +1,27 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct AddHostCommand { + /// The alias for this host + /// + /// Aliases are used to refer to hosts in commands. If not provided, the host can only be + /// referred to by its hostname/IP address. All aliases must be unique. + #[arg(long)] + pub alias: Option, + /// Whether this is the default host + #[arg(long)] + pub default: bool, + /// The API key to use for this host + /// + /// You can create an API key from your account settings in the Parcel web interface. + #[arg(long)] + pub key: String, + /// Whether to use HTTPS + #[arg(long, default_value_t = true)] + #[arg(num_args(0..=1), default_missing_value("true"))] + pub https: bool, + /// The hostname or IP address of the host to add + #[arg(long)] + pub host: String, +} + diff --git a/crates/cli/src/args/host/check.rs b/crates/cli/src/args/host/check.rs new file mode 100644 index 0000000..1416e1a --- /dev/null +++ b/crates/cli/src/args/host/check.rs @@ -0,0 +1,11 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct CheckHostCommand { + /// The alias or host of the host to check + /// + /// This can be the alias you set when adding the host, or the actual hostname/IP address. + #[arg(long)] + pub host: String, +} + diff --git a/crates/cli/src/args/host/list.rs b/crates/cli/src/args/host/list.rs new file mode 100644 index 0000000..3caddf2 --- /dev/null +++ b/crates/cli/src/args/host/list.rs @@ -0,0 +1,5 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct ListHostsCommand {} + diff --git a/crates/cli/src/args/host/remove.rs b/crates/cli/src/args/host/remove.rs new file mode 100644 index 0000000..a26d83f --- /dev/null +++ b/crates/cli/src/args/host/remove.rs @@ -0,0 +1,11 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct RemoveHostCommand { + /// The alias or host of the host to remove + /// + /// This can be the alias you set when adding the host, or the actual hostname/IP address. + #[arg(long)] + pub host: String, +} + diff --git a/crates/cli/src/args/host/set.rs b/crates/cli/src/args/host/set.rs new file mode 100644 index 0000000..30925cc --- /dev/null +++ b/crates/cli/src/args/host/set.rs @@ -0,0 +1,35 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct SetHostCommand { + /// The alias or host of the host to set + /// + /// This can be the alias you set when adding the host, or the actual hostname/IP address. + #[arg(long)] + pub host: String, + /// The new alias for this host + /// + /// This option will assign a new alias to the host. If not provided, the alias will not be + /// changed. + #[arg(long)] + pub alias: Option, + /// Remove the alias from this host + #[arg(long)] + pub remove_alias: bool, + /// Set this host as the default host + /// + /// This will set the host as the default host for all commands that require a host where one + /// is not specified. If a default host is already set, it will be replaced. + #[arg(long)] + pub default: bool, + /// The API key to use for this host + /// + /// You can create an API key from your account settings in the Parcel web interface. + #[arg(long)] + pub key: Option, + /// Whether to use HTTPS + #[arg(long)] + #[arg(num_args(0..=1), default_missing_value("true"))] + pub https: Option, +} + diff --git a/crates/cli/src/args/teams.rs b/crates/cli/src/args/teams.rs new file mode 100644 index 0000000..741358e --- /dev/null +++ b/crates/cli/src/args/teams.rs @@ -0,0 +1,9 @@ +use clap::Subcommand; + +pub mod list; + +#[derive(Debug, Subcommand)] +pub enum TeamsCommand { + /// List all teams you're a member of + List(list::ListTeamsCommand), +} diff --git a/crates/cli/src/args/teams/list.rs b/crates/cli/src/args/teams/list.rs new file mode 100644 index 0000000..c3dbd32 --- /dev/null +++ b/crates/cli/src/args/teams/list.rs @@ -0,0 +1,19 @@ +use clap::Parser; + +use crate::context::Context; + +#[derive(Debug, Parser)] +pub struct ListTeamsCommand { + /// The host to list teams for + /// + /// If not provided, the default host will be used. This can be the alias you set when + /// adding the host, or the actual hostname/IP address. + #[arg(long)] + pub host: Option, +} + +impl ListTeamsCommand { + pub async fn run(self, _context: Context) -> anyhow::Result<()> { + Ok(()) + } +} diff --git a/crates/cli/src/args/upload.rs b/crates/cli/src/args/upload.rs new file mode 100644 index 0000000..391cf7f --- /dev/null +++ b/crates/cli/src/args/upload.rs @@ -0,0 +1,4 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct UploadCommand {} diff --git a/crates/cli/src/args/uploads.rs b/crates/cli/src/args/uploads.rs new file mode 100644 index 0000000..38052c0 --- /dev/null +++ b/crates/cli/src/args/uploads.rs @@ -0,0 +1,26 @@ +use clap::Subcommand; + +pub mod list; +pub mod show; +pub mod set; + +#[derive(Debug, Subcommand)] +pub enum UploadsCommand { + /// List all uploads + /// + /// This command will list all the uploads for your account, or for a specific team (so long as + /// you have access to that team). You can also specify filters to reduce the number of + /// uploads returned, such as by filename or uploader. + List(list::ListUploadsCommand), + /// Show information about a specific upload + /// + /// This will show information about an upload, including its filename, slug, UUID, + /// uploader, and so on. This also includes the link for the upload. + Show(show::ShowUploadCommand), + /// Change the settings for an upload + /// + /// This command allows you to change the settings for an upload, such as changing the + /// filename, adding a custom slug, making the upload public, or setting an expiry date. + Set(set::UploadSetCommand), +} + diff --git a/crates/cli/src/args/uploads/list.rs b/crates/cli/src/args/uploads/list.rs new file mode 100644 index 0000000..2857cf1 --- /dev/null +++ b/crates/cli/src/args/uploads/list.rs @@ -0,0 +1,72 @@ +use clap::{Parser, ValueEnum}; + +#[derive(Debug, Parser)] +pub struct ListUploadsCommand { + /// The host to list teams for. + /// + /// If not provided, the default host will be used. This can be the alias you set when + /// adding the host, or the actual hostname/IP address. + #[arg(long)] + pub host: Option, + /// The team to list uploads for. + /// + /// This should be the slug of the team, or the team's UUID. If not provided, the + /// uplooads for the user will be listed. + #[arg(long)] + pub team: Option, + /// How to sort the uploads. + #[arg(long, default_value = "uploaded-at")] + pub sort: UploadSort, + /// Change the ordering of the uploads. + #[arg(long, default_value = "asc")] + pub order: UploadOrder, + /// Filter the uploads by the given filename. + /// + /// This is a case-insensitive filter that will match any uploads that contain the + /// given filename in their name. + #[arg(long)] + pub filename: Option, + /// Filter the uploads by the given uploader. + /// + /// This is only relevant if the `team` argument is not provided. This argument should + /// be the username of the uploader or their UUID. + #[arg(long)] + pub uploader: Option, + /// Render as JSON instead of a table. + #[arg(long)] + pub json: bool, + /// The maximum number of uploads to return. + /// + /// This is useful for pagination. If not provided, the server limits the number of uploads + /// returned via the API. + #[arg(long, default_value_t = 100)] + pub limit: usize, + /// The offset to start returning uploads from. + /// + /// This is useful for pagination. If not provided, the server will return the first + /// `limit` uploads. + #[arg(long, default_value_t = 0)] + pub offset: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum UploadSort { + /// Order by the upload filename. + Filename, + /// Order by the size of the uploads. + Size, + /// Order by the number of downloads. + Downloads, + /// Order by the date on which the public link will expire. + ExpiryDate, + /// Order by the date on which the upload was created. + UploadedAt, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +#[value(rename_all = "kebab-case")] +pub enum UploadOrder { + Asc, + Desc, +} diff --git a/crates/cli/src/args/uploads/set.rs b/crates/cli/src/args/uploads/set.rs new file mode 100644 index 0000000..e1a36a1 --- /dev/null +++ b/crates/cli/src/args/uploads/set.rs @@ -0,0 +1,98 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct UploadSetCommand { + /// The host to set the upload on. + /// + /// If not provided, the default host will be used. This can be the alias you set when + /// adding the host, or the actual hostname/IP address. + #[arg(long)] + pub host: Option, + /// The team to set uploads for. + /// + /// This should be the slug of the team, or the team's UUID. If not provided, the + /// uplooads for the user will be listed. + #[arg(long)] + pub team: Option, + /// The identification of the upload to set + /// + /// This can be the UUID of the upload, the slug, or the filename. If a custom slug has been + /// specified for the upload, that can also be used. If the filename conflicts with multiple + /// uploads, an error will be returned. + pub upload: String, + + /// Set a new filename for the upload + /// + /// This will change the filename of the upload. If not provided, the filename will not be + /// changed. + #[arg(long)] + pub filename: Option, + + /// Set a custom slug for the upload + /// + /// This will change the custom slug of the upload. If not provided, the custom slug will not + /// be changed. + /// + /// Note that the custom slug is not the same as the internal slug of the upload. Custom slugs + /// must be unique across all uploads (either in your account or team). + #[arg(long)] + pub slug: Option, + + /// Remove the custom slug from the upload + /// + /// This will remove the custom slug from the upload, reverting it to the internal slug. + #[arg(long)] + pub remove_slug: bool, + + /// Change whether the upload is public or private. + /// + /// This argument will change the visibility of the upload. If set to `true`, the upload will + /// be public and accessible via a public link. If set to `false`, the upload will be private + /// and only accessible by yourself or team members. + #[arg(long)] + pub public: Option, + + /// Change the download limit for the upload + /// + /// This will change the number of times that a public upload can be downloaded before it is + /// locked. If not provided, the download limit will not be changed. + #[arg(long)] + pub download_limit: Option, + + /// Remove the download limit for the upload + /// + /// This will remove the download limit for the upload, allowing it to be downloaded an + /// unlimited number of times. + #[arg(long)] + pub remove_download_limit: bool, + + /// Change the expiry date for the upload + /// + /// This will change the date on which the public link for the upload will expire. If not + /// provided, the expiry date will not be changed. + #[arg(long)] + pub expiry_date: Option, + + /// Remove the expiry date for the upload + /// + /// This will remove the expiry date for the upload, allowing it to be accessible via the + /// public link indefinitely. + #[arg(long)] + pub remove_expiry_date: bool, + + /// Set the password for the upload + /// + /// This will set a password for the upload, which will be required to access the public link. + /// If not provided, the password will not be changed. When this argument is provided, the + /// tool will prompt you for the password. + #[arg(long)] + pub password: bool, + + /// Remove the password for the upload + /// + /// This will remove the password for the upload, allowing it to be accessed via the public + /// link without a password. + #[arg(long)] + pub remove_password: bool, +} + diff --git a/crates/cli/src/args/uploads/show.rs b/crates/cli/src/args/uploads/show.rs new file mode 100644 index 0000000..061e848 --- /dev/null +++ b/crates/cli/src/args/uploads/show.rs @@ -0,0 +1,27 @@ +use clap::Parser; + +#[derive(Debug, Parser)] +pub struct ShowUploadCommand { + /// The host to show the upload on. + /// + /// If not provided, the default host will be used. This can be the alias you set when + /// adding the host, or the actual hostname/IP address. + #[arg(long)] + pub host: Option, + /// The team to list uploads for. + /// + /// This should be the slug of the team, or the team's UUID. If not provided, the + /// uplooads for the user will be listed. + #[arg(long)] + pub team: Option, + /// The identification of the upload to show + /// + /// This can be the UUID of the upload, the slug, or the filename. If a custom slug has been + /// specified for the upload, that can also be used. If the filename conflicts with multiple + /// uploads, an error will be returned. + pub upload: String, + /// Render as JSON instead of a table. + #[arg(long)] + pub json: bool, +} + diff --git a/crates/cli/src/bin/parcel.rs b/crates/cli/src/bin/parcel.rs index f328e4d..f525a07 100644 --- a/crates/cli/src/bin/parcel.rs +++ b/crates/cli/src/bin/parcel.rs @@ -1 +1,8 @@ -fn main() {} +use clap::Parser; + +use parcel_cli::args::Args; + +fn main() { + let args = Args::parse(); + println!("{args:#?}"); +} diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs new file mode 100644 index 0000000..08b7fb3 --- /dev/null +++ b/crates/cli/src/config.rs @@ -0,0 +1,110 @@ +use std::{ + io::Write, + path::{Path, PathBuf}, +}; + +use anyhow::Context; +use config::{builder::DefaultState, ConfigBuilder, Environment, File, FileFormat}; +use serde::{Deserialize, Serialize}; + +static EXAMPLE_CONFIG: &str = include_str!("../../../etc/example/config.toml"); + +#[derive(Debug, Serialize, Deserialize)] +pub struct Config { + /// Path to the storage of configured hosts + pub hosts_path: PathBuf, +} + +impl Config { + pub fn load>(config_path: Option

) -> anyhow::Result { + let config_dir = get_config_dir(); + let data_dir = get_data_dir(); + + fs_err::create_dir_all(&config_dir) + .with_context(|| format!("Failed to create config directory: {config_dir:?}"))?; + + fs_err::create_dir_all(&data_dir) + .with_context(|| format!("Failed to create data directory: {data_dir:?}"))?; + + let config_path = if let Some(config_path) = config_path { + config_path.as_ref().to_path_buf() + } else { + config_dir.join("config.toml") + }; + + let builder = Self::builder(config_path, &data_dir)?; + let config = builder.build()?; + let config = config + .try_deserialize::() + .context("failed to deserialize config")?; + + let config = Config { + hosts_path: expand(&config.hosts_path)?, + }; + + Ok(config) + } + + fn builder( + config_path: PathBuf, + data_dir: &Path, + ) -> anyhow::Result> { + let hosts_path = data_dir.join("hosts.json"); + + let mut builder = ConfigBuilder::::default() + .set_default("hosts_path", hosts_path.to_str())? + .add_source(Environment::with_prefix("PARCEL_").separator("_")); + + if config_path.exists() { + builder = builder.add_source(File::new( + config_path.to_str().expect("valid config path"), + FileFormat::Toml, + )); + } else { + let mut file = fs_err::File::create(&config_path) + .with_context(|| format!("Failed to create config file: {config_path:?}"))?; + file.write_all(EXAMPLE_CONFIG.as_bytes()) + .with_context(|| format!("Failed to write example config to: {config_path:?}"))?; + } + + Ok(builder) + } +} + +fn get_home_dir() -> PathBuf { + #[cfg(target_os = "windows")] + { + let home = std::env::var("USERPROFILE").expect("%USERPROFILE% not found"); + PathBuf::from(home) + } + + #[cfg(not(target_os = "windows"))] + { + let home = std::env::var("HOME").expect("$HOME not found"); + PathBuf::from(home) + } +} + +fn get_config_dir() -> PathBuf { + std::env::var("XDG_CONFIG_HOME") + .map_or_else(|_| get_home_dir().join(".config"), PathBuf::from) + .join("parcel") +} + +fn get_data_dir() -> PathBuf { + std::env::var("XDG_DATA_HOME") + .map_or_else( + |_| get_home_dir().join(".local").join("share"), + PathBuf::from, + ) + .join("parcel") +} + +fn expand(path: &PathBuf) -> anyhow::Result { + let result = shellexpand::full( + path.to_str() + .with_context(|| format!("Failed to process path: {path:?}"))?, + ) + .with_context(|| format!("Failed to expand path: {path:?}"))?; + Ok(PathBuf::from(result.into_owned())) +} diff --git a/crates/cli/src/context.rs b/crates/cli/src/context.rs new file mode 100644 index 0000000..608016c --- /dev/null +++ b/crates/cli/src/context.rs @@ -0,0 +1,34 @@ +use std::sync::Arc; + +use crate::config::Config; + +pub struct Context { + pub inner: Arc, +} + +pub struct ContextInner { + pub config: Config, +} + +impl Clone for Context { + fn clone(&self) -> Self { + let inner = Arc::clone(&self.inner); + Self { inner } + } +} + +impl std::ops::Deref for Context { + type Target = ContextInner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl Context { + pub fn new(config: Config) -> Self { + let inner = ContextInner { config }; + let inner = Arc::new(inner); + Self { inner } + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 8b13789..79a5a5d 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1 +1,3 @@ - +pub mod args; +pub mod context; +pub mod config; diff --git a/crates/server/src/app/templates/tailwind.rs b/crates/server/src/app/templates/tailwind.rs index 1537cff..afc9592 100644 --- a/crates/server/src/app/templates/tailwind.rs +++ b/crates/server/src/app/templates/tailwind.rs @@ -79,10 +79,7 @@ impl TailwindRebuilder { })?; for path in paths { - watcher.watch( - &working_dir.join(path), - notify::RecursiveMode::Recursive, - )?; + watcher.watch(&working_dir.join(path), notify::RecursiveMode::Recursive)?; } let inner = Arc::new(Inner { watcher }); diff --git a/etc/example/config.toml b/etc/example/config.toml new file mode 100644 index 0000000..4e5ab5b --- /dev/null +++ b/etc/example/config.toml @@ -0,0 +1,5 @@ +## Where to store the hosts configuration file +## +## Linux & macOS: ~/.local/share/parcel/hosts.json +## Windows: %USERPROFILE%/.local/share/parcel/hosts.json +# hosts_path = "~/.hosts.json"