anvilsign in

collin/anvil

1//! `anvild` — the anvil git forge daemon and admin CLI.
2
3use anvil_core::{
4 App,
5 Config,
6 api_tokens,
7 repos,
8 ssh_keys,
9 users,
10};
11use anyhow::{
12 Context,
13 Result,
14};
15use clap::{
16 Parser,
17 Subcommand,
18};
19
20#[derive(Parser)]
21#[command(name = "anvild", version, about = "anvil git forge")]
22struct Cli {
23 /// Path to the configuration file (TOML). Defaults are used if absent.
24 #[arg(long, short, default_value = "anvil.toml", global = true)]
25 config: String,
26
27 /// Override the data directory from config.
28 #[arg(long, global = true)]
29 data_dir: Option<String>,
30
31 #[command(subcommand)]
32 command: Option<Command>,
33}
34
35#[derive(Subcommand)]
36enum Command {
37 /// Run the server (default).
38 Serve,
39 /// Apply database migrations and exit.
40 Migrate,
41 /// Manage users.
42 User {
43 #[command(subcommand)]
44 command: UserCommand,
45 },
46 /// Manage repositories.
47 Repo {
48 #[command(subcommand)]
49 command: RepoCommand,
50 },
51}
52
53#[derive(Subcommand)]
54enum UserCommand {
55 /// Create a new user.
56 Create {
57 username: String,
58 #[arg(long, default_value = "")]
59 email: String,
60 #[arg(long)]
61 password: String,
62 #[arg(long)]
63 admin: bool,
64 },
65 /// Reset a user's password. Existing sessions stay signed in.
66 SetPassword {
67 username: String,
68 #[arg(long)]
69 password: String,
70 },
71 /// Register an SSH public key for a user (for git-over-SSH access).
72 AddKey {
73 username: String,
74 /// The OpenSSH public key line. Mutually exclusive with --key-file.
75 #[arg(long)]
76 key: Option<String>,
77 /// Path to a `.pub` file (e.g. ~/.ssh/id_ed25519.pub).
78 #[arg(long)]
79 key_file: Option<String>,
80 #[arg(long, default_value = "")]
81 title: String,
82 },
83 /// Manage personal access tokens (read-only API bearer credentials).
84 Token {
85 #[command(subcommand)]
86 command: TokenCommand,
87 },
88}
89
90#[derive(Subcommand)]
91enum TokenCommand {
92 /// Mint a token for a user. The plaintext is printed once — store it now.
93 Create {
94 username: String,
95 /// Human label for the token (shown when listing).
96 #[arg(long, default_value = "api")]
97 name: String,
98 },
99 /// List a user's tokens (id, name, created — never the secret).
100 List { username: String },
101 /// Revoke a token by id.
102 Revoke { id: i64 },
103}
104
105#[derive(Subcommand)]
106enum RepoCommand {
107 /// Create a repository, given as `owner/name`.
108 Create {
109 /// Repository in `owner/name` form.
110 path: String,
111 #[arg(long, default_value = "")]
112 description: String,
113 #[arg(long)]
114 private: bool,
115 },
116}
117
118#[tokio::main]
119async fn main() -> Result<()> {
120 tracing_subscriber::fmt()
121 .with_env_filter(
122 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
123 )
124 .init();
125
126 let cli = Cli::parse();
127
128 let mut config = Config::load_or_default(&cli.config)
129 .with_context(|| format!("loading config from {}", cli.config))?;
130 if let Some(dir) = &cli.data_dir {
131 config.data_dir = dir.into();
132 }
133
134 match cli.command.unwrap_or(Command::Serve) {
135 Command::Serve => serve(config).await,
136 Command::Migrate => migrate(config).await,
137 Command::User { command } => user(config, command).await,
138 Command::Repo { command } => repo(config, command).await,
139 }
140}
141
142async fn serve(config: Config) -> Result<()> {
143 let mut app = App::bootstrap(config).await?;
144
145 // Start the CI runner: it drains queued runs and processes new ones pushed
146 // through `app.ci_tx` (set here so handlers can notify it).
147 let (ci_tx, ci_rx) = tokio::sync::mpsc::unbounded_channel();
148 app.ci_tx = Some(ci_tx);
149 tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx));
150
151 // Start periodic background jobs (language detection, preview images, disk usage cache).
152 let periodic_jobs = vec![
153 (
154 std::time::Duration::from_secs(app.config.periodic.language_detection_interval_secs),
155 Box::new(anvil_core::periodic::LanguageDetectionJob)
156 as Box<dyn anvil_core::periodic::PeriodicJob>,
157 ),
158 (
159 std::time::Duration::from_secs(app.config.periodic.preview_image_interval_secs),
160 Box::new(anvil_core::periodic::PreviewImageJob)
161 as Box<dyn anvil_core::periodic::PeriodicJob>,
162 ),
163 (
164 std::time::Duration::from_secs(app.config.periodic.disk_usage_interval_secs),
165 Box::new(anvil_core::periodic::DiskUsageCacheJob)
166 as Box<dyn anvil_core::periodic::PeriodicJob>,
167 ),
168 ];
169 anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await;
170
171 if app.config.ssh.enabled {
172 // Run the HTTP and SSH servers concurrently; if either exits, stop.
173 tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?;
174 } else {
175 anvil_web::serve(app).await?;
176 }
177 Ok(())
178}
179
180async fn migrate(config: Config) -> Result<()> {
181 App::bootstrap(config).await?;
182 println!("migrations applied");
183 Ok(())
184}
185
186async fn user(config: Config, command: UserCommand) -> Result<()> {
187 let app = App::bootstrap(config).await?;
188 match command {
189 UserCommand::Create {
190 username,
191 email,
192 password,
193 admin,
194 } => {
195 let user = users::create(&app.db, &username, &email, &password, admin).await?;
196 println!(
197 "created user {} (id {}){}",
198 user.username,
199 user.id,
200 if user.is_admin { " [admin]" } else { "" }
201 );
202 }
203 UserCommand::SetPassword { username, password } => {
204 let user = users::find_by_username(&app.db, &username)
205 .await?
206 .with_context(|| format!("no such user: {username}"))?;
207 users::set_password(&app.db, user.id, &password).await?;
208 println!("password reset for {}", user.username);
209 }
210 UserCommand::AddKey {
211 username,
212 key,
213 key_file,
214 title,
215 } => {
216 let user = users::find_by_username(&app.db, &username)
217 .await?
218 .with_context(|| format!("no such user: {username}"))?;
219 let openssh = match (key, key_file) {
220 (Some(k), None) => k,
221 (None, Some(path)) => std::fs::read_to_string(&path)
222 .with_context(|| format!("reading key file {path}"))?,
223 (Some(_), Some(_)) => anyhow::bail!("pass only one of --key / --key-file"),
224 (None, None) => anyhow::bail!("pass --key or --key-file"),
225 };
226 let (fingerprint, content) = ssh_keys::parse_public_key(&openssh)?;
227 let saved = ssh_keys::add(&app.db, user.id, &title, &fingerprint, &content).await?;
228 println!(
229 "added ssh key for {} ({})",
230 user.username, saved.fingerprint
231 );
232 }
233 UserCommand::Token { command } => token(&app, command).await?,
234 }
235 Ok(())
236}
237
238async fn token(app: &App, command: TokenCommand) -> Result<()> {
239 match command {
240 TokenCommand::Create { username, name } => {
241 let user = users::find_by_username(&app.db, &username)
242 .await?
243 .with_context(|| format!("no such user: {username}"))?;
244 let (_, plaintext) =
245 api_tokens::create(&app.db, user.id, &name, api_tokens::READ).await?;
246 println!("created read-only token '{name}' for {username}.");
247 println!("store this now — it won't be shown again:\n\n {plaintext}\n");
248 }
249 TokenCommand::List { username } => {
250 let user = users::find_by_username(&app.db, &username)
251 .await?
252 .with_context(|| format!("no such user: {username}"))?;
253 let tokens = api_tokens::list(&app.db, user.id).await?;
254 if tokens.is_empty() {
255 println!("{username} has no tokens.");
256 }
257 for t in tokens {
258 println!("#{} {} [{}]", t.id, t.name, t.scopes);
259 }
260 }
261 TokenCommand::Revoke { id } => {
262 if api_tokens::revoke(&app.db, id).await? {
263 println!("revoked token #{id}.");
264 } else {
265 anyhow::bail!("no token with id {id}");
266 }
267 }
268 }
269 Ok(())
270}
271
272async fn repo(config: Config, command: RepoCommand) -> Result<()> {
273 let app = App::bootstrap(config).await?;
274 match command {
275 RepoCommand::Create {
276 path,
277 description,
278 private,
279 } => {
280 let (owner_name, name) = path
281 .split_once('/')
282 .context("repository path must be in `owner/name` form")?;
283 let owner = users::find_by_username(&app.db, owner_name)
284 .await?
285 .with_context(|| format!("no such user: {owner_name}"))?;
286 let repo = repos::create(
287 &app.db,
288 &app.config.repositories_dir(),
289 &owner,
290 name,
291 &description,
292 private,
293 )
294 .await?;
295 println!(
296 "created repository {}/{} (id {}) at {}",
297 owner.username,
298 repo.name,
299 repo.id,
300 anvil_core::storage::repo_path(
301 &app.config.repositories_dir(),
302 &owner.username,
303 name
304 )
305 .display()
306 );
307 }
308 }
309 Ok(())
310}