Jump to…
snowinitial commitqoxwzsukwmkx1mo
1//! `dogfood-admin` — operational CLI.
2//!
3//! Spec §4 requires `dogfood-admin reindex --repo <id>`, safe to run against a
4//! live repository, "because you will need it every time indexing logic
5//! changes". Spec §10 adds `reindex --all` as the recovery path when the
6//! database and the repository volume drift apart after a restore.
7
8use anyhow::{Context, Result};
9use clap::{Parser, Subcommand};
10use df_db::ids::new_id;
11use uuid::Uuid;
12
13#[derive(Parser)]
14#[command(name = "dogfood-admin", about = "Dogfood operational commands")]
15struct Cli {
16 #[arg(long, env = "DATABASE_URL")]
17 database_url: String,
18
19 #[command(subcommand)]
20 command: Command,
21}
22
23#[derive(Subcommand)]
24enum Command {
25 /// Re-run indexing. Safe against a live repository.
26 Reindex {
27 /// Repository UUID. Omit with --all.
28 #[arg(long)]
29 repo: Option<Uuid>,
30 /// Reindex every repository.
31 #[arg(long)]
32 all: bool,
33 },
34 /// Manage personal access tokens.
35 Token {
36 #[command(subcommand)]
37 command: TokenCommand,
38 },
39 /// Manage users.
40 User {
41 #[command(subcommand)]
42 command: UserCommand,
43 },
44 /// Invite an email address to a closed instance.
45 Invite {
46 #[arg(long)]
47 email: String,
48 },
49}
50
51#[derive(Subcommand)]
52enum TokenCommand {
53 /// Mint a token. The plaintext is printed once and never stored.
54 Create {
55 #[arg(long)]
56 handle: String,
57 #[arg(long, default_value = "admin-cli")]
58 name: String,
59 },
60 List {
61 #[arg(long)]
62 handle: String,
63 },
64}
65
66#[derive(Subcommand)]
67enum UserCommand {
68 List,
69 /// Grant or revoke site admin.
70 Admin {
71 #[arg(long)]
72 handle: String,
73 #[arg(long)]
74 revoke: bool,
75 },
76}
77
78#[tokio::main]
79async fn main() -> Result<()> {
80 let _ = dotenvy::dotenv();
81 let cli = Cli::parse();
82
83 let db = df_db::connect(&cli.database_url, 4)
84 .await
85 .context("connecting to the database")?;
86
87 match cli.command {
88 Command::Reindex { repo, all } => reindex(&db, repo, all).await,
89 Command::Token { command } => match command {
90 TokenCommand::Create { handle, name } => create_token(&db, &handle, &name).await,
91 TokenCommand::List { handle } => list_tokens(&db, &handle).await,
92 },
93 Command::User { command } => match command {
94 UserCommand::List => list_users(&db).await,
95 UserCommand::Admin { handle, revoke } => set_admin(&db, &handle, !revoke).await,
96 },
97 Command::Invite { email } => invite(&db, &email).await,
98 }
99}
100
101/// Enqueue indexing rather than doing it inline.
102///
103/// The worker owns indexing, so routing through the queue means the CLI and a
104/// push take exactly the same code path — there is no second implementation to
105/// drift.
106async fn reindex(db: &df_db::PgPool, repo: Option<Uuid>, all: bool) -> Result<()> {
107 let repos: Vec<(Uuid,)> = if all {
108 sqlx::query_as("SELECT id FROM repos ORDER BY created_at")
109 .fetch_all(db)
110 .await?
111 } else {
112 let id = repo.context("pass --repo <uuid> or --all")?;
113 sqlx::query_as("SELECT id FROM repos WHERE id = $1")
114 .bind(id)
115 .fetch_all(db)
116 .await?
117 };
118
119 if repos.is_empty() {
120 println!("no matching repositories");
121 return Ok(());
122 }
123
124 for (id,) in &repos {
125 sqlx::query("INSERT INTO jobs (id, kind, payload) VALUES ($1, 'index_push', $2)")
126 .bind(new_id())
127 .bind(serde_json::json!({ "repo_id": id }))
128 .execute(db)
129 .await?;
130 println!("queued reindex for {id}");
131 }
132
133 println!("\n{} job(s) queued; the worker will pick them up.", repos.len());
134 Ok(())
135}
136
137async fn create_token(db: &df_db::PgPool, handle: &str, name: &str) -> Result<()> {
138 let (user_id,): (Uuid,) = sqlx::query_as("SELECT id FROM users WHERE handle = $1")
139 .bind(handle)
140 .fetch_optional(db)
141 .await?
142 .with_context(|| format!("no user with handle {handle}"))?;
143
144 let token = df_auth::tokens::create(db, user_id, name, &[], None).await?;
145
146 println!("Token created for {handle}.");
147 println!("\n {}\n", token.plaintext);
148 println!("This is shown once — only an Argon2id hash is stored.");
149 println!("Use it as the password for git over HTTPS, with {handle} as the username.");
150 Ok(())
151}
152
153async fn list_tokens(db: &df_db::PgPool, handle: &str) -> Result<()> {
154 let rows: Vec<(String, String, Option<chrono::DateTime<chrono::Utc>>)> = sqlx::query_as(
155 "SELECT t.name, t.prefix, t.last_used_at
156 FROM access_tokens t JOIN users u ON u.id = t.user_id
157 WHERE u.handle = $1 ORDER BY t.created_at DESC",
158 )
159 .bind(handle)
160 .fetch_all(db)
161 .await?;
162
163 if rows.is_empty() {
164 println!("no tokens for {handle}");
165 return Ok(());
166 }
167 for (name, prefix, used) in rows {
168 println!(
169 "{prefix}… {name:<24} last used: {}",
170 used.map(|t| t.to_rfc3339()).unwrap_or_else(|| "never".into())
171 );
172 }
173 Ok(())
174}
175
176async fn list_users(db: &df_db::PgPool) -> Result<()> {
177 let rows: Vec<(String, Option<String>, bool)> =
178 sqlx::query_as("SELECT handle::text, email::text, is_admin FROM users ORDER BY created_at")
179 .fetch_all(db)
180 .await?;
181 for (handle, email, admin) in rows {
182 println!(
183 "{handle:<20} {:<32} {}",
184 email.unwrap_or_else(|| "(no email)".into()),
185 if admin { "admin" } else { "" }
186 );
187 }
188 Ok(())
189}
190
191async fn set_admin(db: &df_db::PgPool, handle: &str, grant: bool) -> Result<()> {
192 let r = sqlx::query("UPDATE users SET is_admin = $2 WHERE handle = $1")
193 .bind(handle)
194 .bind(grant)
195 .execute(db)
196 .await?;
197 if r.rows_affected() == 0 {
198 anyhow::bail!("no user with handle {handle}");
199 }
200 println!(
201 "{handle} is {} a site administrator",
202 if grant { "now" } else { "no longer" }
203 );
204 Ok(())
205}
206
207async fn invite(db: &df_db::PgPool, email: &str) -> Result<()> {
208 sqlx::query(
209 "INSERT INTO invitations (id, email) VALUES ($1, $2)
210 ON CONFLICT (email) DO NOTHING",
211 )
212 .bind(new_id())
213 .bind(email)
214 .execute(db)
215 .await?;
216 println!("{email} may now sign in.");
217 Ok(())
218}

218 lines · Rust