pub mod args;
pub mod away;
pub mod check_cmd;
pub mod key_cmd;
pub mod network;
pub mod profile_cmd;
pub mod prompt;
pub mod setup_cmd;
pub mod use_cmd;
use std::ffi::OsString;
use clap::Parser;
use crate::config::name::ProfileName;
use crate::config::paths::ConfigDir;
use crate::config::resolve::{Inputs, resolve};
use crate::config::store::Store;
use crate::error::{EXIT_ERROR, Error};
use crate::git::classify::{self, Kind};
use crate::git::repo::Repo;
use crate::git::runner::Git;
use crate::proxy;
pub const OWN_COMMANDS: &[&str] = &["setup", "profile", "use", "peer", "key", "check"];
pub const RESERVED_COMMANDS: &[&str] = &["list", "tunnel"];
const HELP: &str = "\
gitaway: reach your self-hosted git server from anywhere
Usage: gitaway [--profile <name>] <command> [args]
gitaway's own commands:
setup Set up a profile from the VPN config your server gave this device
check Test a profile's tunnel and pin its home server's host key
profile Add, list, show, change and remove profiles
use Pin the current repository to a profile
peer Print what the WireGuard server needs to know about this device
key Replace or import this device's WireGuard key
Every other command goes to git. Network commands (clone, fetch, pull, push,
ls-remote, remote update, submodule update) first check whether you're at the
remote's home network.
Options:
--profile <name> Use this profile (must come before any git options)
-h, --help Show this help
--version Show gitaway's version
Run `gitaway <command> --help` for a command's options.";
pub fn main() -> i32 {
let args: Vec<OsString> = std::env::args_os().skip(1).collect();
match run(args) {
Ok(code) => code,
Err(e) => {
eprintln!("gitaway: error: {e}");
EXIT_ERROR
}
}
}
pub fn run(args: Vec<OsString>) -> Result<i32, Error> {
if args
.first()
.is_some_and(|first| first == proxy::helper::COMMAND)
{
let helper_args: Vec<String> = args[1..]
.iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
return Ok(proxy::helper::run(&helper_args));
}
let (profile_flag, args) = take_profile_flag(args)?;
if args.is_empty() || matches_only(&args, &["-h", "--help"]) {
println!("{HELP}");
return Ok(0);
}
if matches_only(&args, &["--version"]) {
println!("gitaway {}", env!("CARGO_PKG_VERSION"));
return Ok(0);
}
let invocation = classify::classify(&args);
match invocation.subcommand {
Some(command) if OWN_COMMANDS.contains(&command) => run_own_command(
&args[invocation.global_options.len()..],
invocation.global_options,
profile_flag.as_ref(),
),
Some(command) if RESERVED_COMMANDS.contains(&command) => Err(Error::msg(format!(
"`gitaway {command}` isn't available yet; it's planned for a later release"
))),
_ if invocation.kind == Kind::Network => {
match network::plan(&invocation, profile_flag.as_ref())? {
network::Plan::Direct(git_args) => Err(Git::new(&[]).exec(&git_args).into()),
network::Plan::Away(plan) => away::run(*plan),
}
}
_ => {
if profile_flag.is_some() {
eprintln!("gitaway: note: --profile has no effect on local commands");
}
Err(Git::new(&[]).exec(&args).into())
}
}
}
pub(crate) fn open_store() -> Result<Store, Error> {
Ok(Store::new(ConfigDir::from_env()?))
}
fn run_own_command(
args: &[OsString],
global_options: &[OsString],
profile_flag: Option<&ProfileName>,
) -> Result<i32, Error> {
let cli = match args::Cli::try_parse_from(
std::iter::once(OsString::from("gitaway")).chain(args.iter().cloned()),
) {
Ok(cli) => cli,
Err(e) => e.exit(),
};
match cli.command {
args::Command::Profile(command) => profile_cmd::run(command),
args::Command::Setup(command) => setup_cmd::run(&open_store()?, command),
args::Command::Use(command) => use_cmd::run(command, global_options),
args::Command::Peer(command) => {
let store = open_store()?;
let name = pick_profile(&store, command.profile, profile_flag, global_options)?;
key_cmd::peer(&store, &name)
}
args::Command::Key(args::KeyCommand::Rotate { profile, yes }) => {
let store = open_store()?;
let name = pick_profile(&store, profile, profile_flag, global_options)?;
key_cmd::rotate(&store, &name, yes)
}
args::Command::Check(command) => {
let store = open_store()?;
let name = pick_profile(&store, command.profile, profile_flag, global_options)?;
check_cmd::run(&store, &name, command.yes)
}
args::Command::Key(args::KeyCommand::Import { profile, yes }) => {
let store = open_store()?;
let name = pick_profile(&store, profile, profile_flag, global_options)?;
key_cmd::import(&store, &name, yes)
}
}
}
fn pick_profile(
store: &Store,
named: Option<String>,
profile_flag: Option<&ProfileName>,
global_options: &[OsString],
) -> Result<ProfileName, Error> {
let named = named
.map(|name| name.parse::<ProfileName>())
.transpose()
.map_err(|e| Error::msg(e.to_string()))?;
let flag = named.as_ref().or(profile_flag);
let env = network::env_profile()?;
let git_config = match flag.or(env.as_ref()) {
Some(_) => None,
None => Repo::new(Git::new(global_options))
.profile()
.ok()
.flatten()
.map(|name| name.parse::<ProfileName>())
.transpose()
.map_err(|e| {
Error::msg(format!("git config {}: {e}", crate::git::repo::PROFILE_KEY))
})?,
};
let global = store.load_global()?;
let profiles = store.load_profiles()?;
let inputs = Inputs {
flag,
env: env.as_ref(),
git_config: git_config.as_ref(),
remote: None,
default: global.default_profile.as_ref(),
};
match resolve(inputs, &profiles, |_, _| false)? {
Some(resolved) => Ok(resolved.name.clone()),
None => Err(Error::msg(
"no profile chosen: name one, or set a default with `gitaway profile default <name>`",
)),
}
}
fn take_profile_flag(
mut args: Vec<OsString>,
) -> Result<(Option<ProfileName>, Vec<OsString>), Error> {
let Some(first) = args.first().and_then(|a| a.to_str()) else {
return Ok((None, args));
};
let (value, consumed) = if first == "--profile" {
let value = args
.get(1)
.and_then(|v| v.to_str())
.ok_or_else(|| Error::msg("--profile needs a profile name"))?;
(value.to_owned(), 2)
} else if let Some(value) = first.strip_prefix("--profile=") {
(value.to_owned(), 1)
} else {
return Ok((None, args));
};
let name = value
.parse()
.map_err(|e| Error::msg(format!("--profile: {e}")))?;
args.drain(..consumed);
Ok((Some(name), args))
}
fn matches_only(args: &[OsString], options: &[&str]) -> bool {
matches!(args, [only] if options.iter().any(|o| only == o))
}