anvilsign in

collin/anvil

1//! `anvild` — the anvil git forge daemon and admin CLI.
2
3use anvil_core::{
4 App,
5 Config,
6 repos,
7 ssh_keys,
8 users,
9};
10use anyhow::{
11 Context,
12 Result,
13};
14use clap::{
15 Parser,
16 Subcommand,
17};
18
19#[derive(Parser)]
20#[command(name = "anvild", version, about = "anvil git forge")]
21struct Cli {
22 /// Path to the configuration file (TOML). Defaults are used if absent.
23 #[arg(long, short, default_value = "anvil.toml", global = true)]
24 config: String,
25
26 /// Override the data directory from config.
27 #[arg(long, global = true)]
28 data_dir: Option<String>,
29
30 #[command(subcommand)]
31 command: Option<Command>,
32}
33
34#[derive(Subcommand)]
35enum Command {
36 /// Run the server (default).
37 Serve,
38 /// Apply database migrations and exit.
39 Migrate,
40 /// Manage users.
41 User {
42 #[command(subcommand)]
43 command: UserCommand,
44 },
45 /// Manage repositories.
46 Repo {
47 #[command(subcommand)]
48 command: RepoCommand,
49 },
50}
51
52#[derive(Subcommand)]
53enum UserCommand {
54 /// Create a new user.
55 Create {
56 username: String,
57 #[arg(long, default_value = "")]
58 email: String,
59 #[arg(long)]
60 password: String,
61 #[arg(long)]
62 admin: bool,
63 },
64 /// Register an SSH public key for a user (for git-over-SSH access).
65 AddKey {
66 username: String,
67 /// The OpenSSH public key line. Mutually exclusive with --key-file.
68 #[arg(long)]
69 key: Option<String>,
70 /// Path to a `.pub` file (e.g. ~/.ssh/id_ed25519.pub).
71 #[arg(long)]
72 key_file: Option<String>,
73 #[arg(long, default_value = "")]
74 title: String,
75 },
76}
77
78#[derive(Subcommand)]
79enum RepoCommand {
80 /// Create a repository, given as `owner/name`.
81 Create {
82 /// Repository in `owner/name` form.
83 path: String,
84 #[arg(long, default_value = "")]
85 description: String,
86 #[arg(long)]
87 private: bool,
88 },
89}
90
91#[tokio::main]
92async fn main() -> Result<()> {
93 tracing_subscriber::fmt()
94 .with_env_filter(
95 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
96 )
97 .init();
98
99 let cli = Cli::parse();
100
101 let mut config = Config::load_or_default(&cli.config)
102 .with_context(|| format!("loading config from {}", cli.config))?;
103 if let Some(dir) = &cli.data_dir {
104 config.data_dir = dir.into();
105 }
106
107 match cli.command.unwrap_or(Command::Serve) {
108 Command::Serve => serve(config).await,
109 Command::Migrate => migrate(config).await,
110 Command::User { command } => user(config, command).await,
111 Command::Repo { command } => repo(config, command).await,
112 }
113}
114
115async fn serve(config: Config) -> Result<()> {
116 let mut app = App::bootstrap(config).await?;
117
118 // Start the CI runner: it drains queued runs and processes new ones pushed
119 // through `app.ci_tx` (set here so handlers can notify it).
120 let (ci_tx, ci_rx) = tokio::sync::mpsc::unbounded_channel();
121 app.ci_tx = Some(ci_tx);
122 tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx));
123
124 if app.config.ssh.enabled {
125 // Run the HTTP and SSH servers concurrently; if either exits, stop.
126 tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?;
127 } else {
128 anvil_web::serve(app).await?;
129 }
130 Ok(())
131}
132
133async fn migrate(config: Config) -> Result<()> {
134 App::bootstrap(config).await?;
135 println!("migrations applied");
136 Ok(())
137}
138
139async fn user(config: Config, command: UserCommand) -> Result<()> {
140 let app = App::bootstrap(config).await?;
141 match command {
142 UserCommand::Create {
143 username,
144 email,
145 password,
146 admin,
147 } => {
148 let user = users::create(&app.db, &username, &email, &password, admin).await?;
149 println!(
150 "created user {} (id {}){}",
151 user.username,
152 user.id,
153 if user.is_admin { " [admin]" } else { "" }
154 );
155 }
156 UserCommand::AddKey {
157 username,
158 key,
159 key_file,
160 title,
161 } => {
162 let user = users::find_by_username(&app.db, &username)
163 .await?
164 .with_context(|| format!("no such user: {username}"))?;
165 let openssh = match (key, key_file) {
166 (Some(k), None) => k,
167 (None, Some(path)) => std::fs::read_to_string(&path)
168 .with_context(|| format!("reading key file {path}"))?,
169 (Some(_), Some(_)) => anyhow::bail!("pass only one of --key / --key-file"),
170 (None, None) => anyhow::bail!("pass --key or --key-file"),
171 };
172 let (fingerprint, content) = ssh_keys::parse_public_key(&openssh)?;
173 let saved = ssh_keys::add(&app.db, user.id, &title, &fingerprint, &content).await?;
174 println!(
175 "added ssh key for {} ({})",
176 user.username, saved.fingerprint
177 );
178 }
179 }
180 Ok(())
181}
182
183async fn repo(config: Config, command: RepoCommand) -> Result<()> {
184 let app = App::bootstrap(config).await?;
185 match command {
186 RepoCommand::Create {
187 path,
188 description,
189 private,
190 } => {
191 let (owner_name, name) = path
192 .split_once('/')
193 .context("repository path must be in `owner/name` form")?;
194 let owner = users::find_by_username(&app.db, owner_name)
195 .await?
196 .with_context(|| format!("no such user: {owner_name}"))?;
197 let repo = repos::create(
198 &app.db,
199 &app.config.repositories_dir(),
200 &owner,
201 name,
202 &description,
203 private,
204 )
205 .await?;
206 println!(
207 "created repository {}/{} (id {}) at {}",
208 owner.username,
209 repo.name,
210 repo.id,
211 anvil_core::storage::repo_path(
212 &app.config.repositories_dir(),
213 &owner.username,
214 name
215 )
216 .display()
217 );
218 }
219 }
220 Ok(())
221}