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 if app.config.ssh.enabled {
152 // Run the HTTP and SSH servers concurrently; if either exits, stop.
153 tokio::try_join!(anvil_web::serve(app.clone()), anvil_ssh::serve(app))?;
154 } else {
155 anvil_web::serve(app).await?;
156 }
157 Ok(())
158}
159
160async fn migrate(config: Config) -> Result<()> {
161 App::bootstrap(config).await?;
162 println!("migrations applied");
163 Ok(())
164}
165
166async fn user(config: Config, command: UserCommand) -> Result<()> {
167 let app = App::bootstrap(config).await?;
168 match command {
169 UserCommand::Create {
170 username,
171 email,
172 password,
173 admin,
174 } => {
175 let user = users::create(&app.db, &username, &email, &password, admin).await?;
176 println!(
177 "created user {} (id {}){}",
178 user.username,
179 user.id,
180 if user.is_admin { " [admin]" } else { "" }
181 );
182 }
183 UserCommand::SetPassword { username, password } => {
184 let user = users::find_by_username(&app.db, &username)
185 .await?
186 .with_context(|| format!("no such user: {username}"))?;
187 users::set_password(&app.db, user.id, &password).await?;
188 println!("password reset for {}", user.username);
189 }
190 UserCommand::AddKey {
191 username,
192 key,
193 key_file,
194 title,
195 } => {
196 let user = users::find_by_username(&app.db, &username)
197 .await?
198 .with_context(|| format!("no such user: {username}"))?;
199 let openssh = match (key, key_file) {
200 (Some(k), None) => k,
201 (None, Some(path)) => std::fs::read_to_string(&path)
202 .with_context(|| format!("reading key file {path}"))?,
203 (Some(_), Some(_)) => anyhow::bail!("pass only one of --key / --key-file"),
204 (None, None) => anyhow::bail!("pass --key or --key-file"),
205 };
206 let (fingerprint, content) = ssh_keys::parse_public_key(&openssh)?;
207 let saved = ssh_keys::add(&app.db, user.id, &title, &fingerprint, &content).await?;
208 println!(
209 "added ssh key for {} ({})",
210 user.username, saved.fingerprint
211 );
212 }
213 UserCommand::Token { command } => token(&app, command).await?,
214 }
215 Ok(())
216}
217
218async fn token(app: &App, command: TokenCommand) -> Result<()> {
219 match command {
220 TokenCommand::Create { username, name } => {
221 let user = users::find_by_username(&app.db, &username)
222 .await?
223 .with_context(|| format!("no such user: {username}"))?;
224 let (_, plaintext) =
225 api_tokens::create(&app.db, user.id, &name, api_tokens::READ).await?;
226 println!("created read-only token '{name}' for {username}.");
227 println!("store this now — it won't be shown again:\n\n {plaintext}\n");
228 }
229 TokenCommand::List { username } => {
230 let user = users::find_by_username(&app.db, &username)
231 .await?
232 .with_context(|| format!("no such user: {username}"))?;
233 let tokens = api_tokens::list(&app.db, user.id).await?;
234 if tokens.is_empty() {
235 println!("{username} has no tokens.");
236 }
237 for t in tokens {
238 println!("#{} {} [{}]", t.id, t.name, t.scopes);
239 }
240 }
241 TokenCommand::Revoke { id } => {
242 if api_tokens::revoke(&app.db, id).await? {
243 println!("revoked token #{id}.");
244 } else {
245 anyhow::bail!("no token with id {id}");
246 }
247 }
248 }
249 Ok(())
250}
251
252async fn repo(config: Config, command: RepoCommand) -> Result<()> {
253 let app = App::bootstrap(config).await?;
254 match command {
255 RepoCommand::Create {
256 path,
257 description,
258 private,
259 } => {
260 let (owner_name, name) = path
261 .split_once('/')
262 .context("repository path must be in `owner/name` form")?;
263 let owner = users::find_by_username(&app.db, owner_name)
264 .await?
265 .with_context(|| format!("no such user: {owner_name}"))?;
266 let repo = repos::create(
267 &app.db,
268 &app.config.repositories_dir(),
269 &owner,
270 name,
271 &description,
272 private,
273 )
274 .await?;
275 println!(
276 "created repository {}/{} (id {}) at {}",
277 owner.username,
278 repo.name,
279 repo.id,
280 anvil_core::storage::repo_path(
281 &app.config.repositories_dir(),
282 &owner.username,
283 name
284 )
285 .display()
286 );
287 }
288 }
289 Ok(())
290}