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 /// Register an SSH public key for a user (for git-over-SSH access).
66 AddKey {
67 username: String,
68 /// The OpenSSH public key line. Mutually exclusive with --key-file.
69 #[arg(long)]
70 key: Option<String>,
71 /// Path to a `.pub` file (e.g. ~/.ssh/id_ed25519.pub).
72 #[arg(long)]
73 key_file: Option<String>,
74 #[arg(long, default_value = "")]
75 title: String,
76 },
77 /// Manage personal access tokens (read-only API bearer credentials).
78 Token {
79 #[command(subcommand)]
80 command: TokenCommand,
81 },
82}
83
84#[derive(Subcommand)]
85enum TokenCommand {
86 /// Mint a token for a user. The plaintext is printed once — store it now.
87 Create {
88 username: String,
89 /// Human label for the token (shown when listing).
90 #[arg(long, default_value = "api")]
91 name: String,
92 },
93 /// List a user's tokens (id, name, created — never the secret).
94 List { username: String },
95 /// Revoke a token by id.
96 Revoke { id: i64 },
97}
98
99#[derive(Subcommand)]
100enum RepoCommand {
101 /// Create a repository, given as `owner/name`.
102 Create {
103 /// Repository in `owner/name` form.
104 path: String,
105 #[arg(long, default_value = "")]
106 description: String,
107 #[arg(long)]
108 private: bool,
109 },
110}
111
112#[tokio::main]
113async fn main() -> Result<()> {
114 tracing_subscriber::fmt()
115 .with_env_filter(
116 tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
117 )
118 .init();
119
120 let cli = Cli::parse();
121
122 let mut config = Config::load_or_default(&cli.config)
123 .with_context(|| format!("loading config from {}", cli.config))?;
124 if let Some(dir) = &cli.data_dir {
125 config.data_dir = dir.into();
126 }
127
128 match cli.command.unwrap_or(Command::Serve) {
129 Command::Serve => serve(config).await,
130 Command::Migrate => migrate(config).await,
131 Command::User { command } => user(config, command).await,
132 Command::Repo { command } => repo(config, command).await,
133 }
134}
135
136async fn serve(config: Config) -> Result<()> {
137 let mut app = App::bootstrap(config).await?;
138
139 // Start the CI runner: it drains queued runs and processes new ones pushed
140 // through `app.ci_tx` (set here so handlers can notify it).
141 let (ci_tx, ci_rx) = tokio::sync::mpsc::unbounded_channel();
142 app.ci_tx = Some(ci_tx);
143 tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx));
144
145 if app.config.ssh.enabled {
146 // Run the HTTP and SSH servers concurrently; if either exits, stop.
147 tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?;
148 } else {
149 anvil_web::serve(app).await?;
150 }
151 Ok(())
152}
153
154async fn migrate(config: Config) -> Result<()> {
155 App::bootstrap(config).await?;
156 println!("migrations applied");
157 Ok(())
158}
159
160async fn user(config: Config, command: UserCommand) -> Result<()> {
161 let app = App::bootstrap(config).await?;
162 match command {
163 UserCommand::Create {
164 username,
165 email,
166 password,
167 admin,
168 } => {
169 let user = users::create(&app.db, &username, &email, &password, admin).await?;
170 println!(
171 "created user {} (id {}){}",
172 user.username,
173 user.id,
174 if user.is_admin { " [admin]" } else { "" }
175 );
176 }
177 UserCommand::AddKey {
178 username,
179 key,
180 key_file,
181 title,
182 } => {
183 let user = users::find_by_username(&app.db, &username)
184 .await?
185 .with_context(|| format!("no such user: {username}"))?;
186 let openssh = match (key, key_file) {
187 (Some(k), None) => k,
188 (None, Some(path)) => std::fs::read_to_string(&path)
189 .with_context(|| format!("reading key file {path}"))?,
190 (Some(_), Some(_)) => anyhow::bail!("pass only one of --key / --key-file"),
191 (None, None) => anyhow::bail!("pass --key or --key-file"),
192 };
193 let (fingerprint, content) = ssh_keys::parse_public_key(&openssh)?;
194 let saved = ssh_keys::add(&app.db, user.id, &title, &fingerprint, &content).await?;
195 println!(
196 "added ssh key for {} ({})",
197 user.username, saved.fingerprint
198 );
199 }
200 UserCommand::Token { command } => token(&app, command).await?,
201 }
202 Ok(())
203}
204
205async fn token(app: &App, command: TokenCommand) -> Result<()> {
206 match command {
207 TokenCommand::Create { username, name } => {
208 let user = users::find_by_username(&app.db, &username)
209 .await?
210 .with_context(|| format!("no such user: {username}"))?;
211 let (_, plaintext) =
212 api_tokens::create(&app.db, user.id, &name, api_tokens::READ).await?;
213 println!("created read-only token '{name}' for {username}.");
214 println!("store this now — it won't be shown again:\n\n {plaintext}\n");
215 }
216 TokenCommand::List { username } => {
217 let user = users::find_by_username(&app.db, &username)
218 .await?
219 .with_context(|| format!("no such user: {username}"))?;
220 let tokens = api_tokens::list(&app.db, user.id).await?;
221 if tokens.is_empty() {
222 println!("{username} has no tokens.");
223 }
224 for t in tokens {
225 println!("#{} {} [{}]", t.id, t.name, t.scopes);
226 }
227 }
228 TokenCommand::Revoke { id } => {
229 if api_tokens::revoke(&app.db, id).await? {
230 println!("revoked token #{id}.");
231 } else {
232 anyhow::bail!("no token with id {id}");
233 }
234 }
235 }
236 Ok(())
237}
238
239async fn repo(config: Config, command: RepoCommand) -> Result<()> {
240 let app = App::bootstrap(config).await?;
241 match command {
242 RepoCommand::Create {
243 path,
244 description,
245 private,
246 } => {
247 let (owner_name, name) = path
248 .split_once('/')
249 .context("repository path must be in `owner/name` form")?;
250 let owner = users::find_by_username(&app.db, owner_name)
251 .await?
252 .with_context(|| format!("no such user: {owner_name}"))?;
253 let repo = repos::create(
254 &app.db,
255 &app.config.repositories_dir(),
256 &owner,
257 name,
258 &description,
259 private,
260 )
261 .await?;
262 println!(
263 "created repository {}/{} (id {}) at {}",
264 owner.username,
265 repo.name,
266 repo.id,
267 anvil_core::storage::repo_path(
268 &app.config.repositories_dir(),
269 &owner.username,
270 name
271 )
272 .display()
273 );
274 }
275 }
276 Ok(())
277}