Skip to content

Commit bc4ac87

Browse files
committed
Add users command for bootstrap user creation
Enables self-hosted deployments to create the first user without requiring an existing user context. Users can now run: ow local users create admin This resolves the chicken-and-egg problem where DB aliases required a --user flag but there was no way to create users via CLI. Bumps version to 0.2.2.
1 parent 888b8a9 commit bc4ac87

6 files changed

Lines changed: 258 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "openworkers-cli"
3-
version = "0.2.1"
3+
version = "0.2.2"
44
edition = "2024"
55
license = "MIT"
66

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ ow workers deploy my-api worker.ts
2727
| `storage` | `s` | S3/R2 storage configurations |
2828
| `kv` | `k` | Key-value namespaces |
2929
| `databases` | `d` | SQL database bindings |
30+
| `users` | `u` | User management (DB only) |
3031
| `alias` | | Backend connection aliases |
3132
| `login` | | Authenticate with API |
3233
| `migrate` | | Database schema migrations |

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ pub mod kv;
55
pub mod login;
66
pub mod migrate;
77
pub mod storage;
8+
pub mod users;
89
pub mod workers;

src/commands/users.rs

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
use crate::config::{AliasConfig, Config, ConfigError};
2+
use clap::Subcommand;
3+
use colored::Colorize;
4+
use sqlx::postgres::PgPoolOptions;
5+
use sqlx::{PgPool, Row};
6+
7+
#[derive(Debug, thiserror::Error)]
8+
pub enum UsersError {
9+
#[error("Config error: {0}")]
10+
Config(#[from] ConfigError),
11+
12+
#[error("Database error: {0}")]
13+
Sqlx(#[from] sqlx::Error),
14+
15+
#[error("Alias '{0}' is not a database alias. Use --db when creating the alias.")]
16+
NotDbAlias(String),
17+
18+
#[error("No alias specified and no default alias configured")]
19+
NoAlias,
20+
21+
#[error("User '{0}' not found")]
22+
UserNotFound(String),
23+
24+
#[error("User '{0}' already exists")]
25+
UserExists(String),
26+
}
27+
28+
#[derive(Subcommand)]
29+
pub enum UsersCommand {
30+
/// List all users
31+
#[command(alias = "ls", after_help = "Example:\n ow local users list")]
32+
List,
33+
34+
/// Show user details
35+
#[command(after_help = "Example:\n ow local users get admin")]
36+
Get {
37+
/// Username
38+
username: String,
39+
},
40+
41+
/// Create a new user (bootstrap mode - no user required)
42+
#[command(after_help = "Examples:\n \
43+
ow local users create admin\n \
44+
ow local users create max")]
45+
Create {
46+
/// Username for the new user
47+
username: String,
48+
},
49+
50+
/// Delete a user
51+
#[command(alias = "rm", after_help = "Example:\n ow local users delete old-user")]
52+
Delete {
53+
/// Username to delete
54+
username: String,
55+
},
56+
}
57+
58+
impl UsersCommand {
59+
pub async fn run(self, alias: Option<String>) -> Result<(), UsersError> {
60+
let database_url = resolve_database_url(alias)?;
61+
let pool = connect(&database_url).await?;
62+
63+
match self {
64+
Self::List => cmd_list(&pool).await,
65+
Self::Get { username } => cmd_get(&pool, &username).await,
66+
Self::Create { username } => cmd_create(&pool, username).await,
67+
Self::Delete { username } => cmd_delete(&pool, &username).await,
68+
}
69+
}
70+
}
71+
72+
fn resolve_database_url(alias: Option<String>) -> Result<String, UsersError> {
73+
let config = Config::load()?;
74+
75+
let alias_name = alias
76+
.or(config.default.clone())
77+
.ok_or(UsersError::NoAlias)?;
78+
79+
let alias_config = config
80+
.get_alias(&alias_name)
81+
.ok_or_else(|| ConfigError::AliasNotFound(alias_name.clone()))?;
82+
83+
match alias_config {
84+
AliasConfig::Db { database_url, .. } => Ok(database_url.clone()),
85+
AliasConfig::Api { .. } => Err(UsersError::NotDbAlias(alias_name)),
86+
}
87+
}
88+
89+
async fn connect(database_url: &str) -> Result<PgPool, UsersError> {
90+
let pool = PgPoolOptions::new()
91+
.max_connections(1)
92+
.connect(database_url)
93+
.await?;
94+
95+
Ok(pool)
96+
}
97+
98+
async fn cmd_list(pool: &PgPool) -> Result<(), UsersError> {
99+
let rows = sqlx::query(
100+
r#"
101+
SELECT id, username, created_at
102+
FROM users
103+
ORDER BY created_at
104+
"#,
105+
)
106+
.fetch_all(pool)
107+
.await?;
108+
109+
if rows.is_empty() {
110+
println!("No users found.");
111+
return Ok(());
112+
}
113+
114+
println!("{}", "Users".bold());
115+
println!("{}", "─".repeat(60));
116+
117+
for row in rows {
118+
let username: String = row.get("username");
119+
let id: uuid::Uuid = row.get("id");
120+
let created_at: chrono::NaiveDateTime = row.get("created_at");
121+
122+
println!(
123+
" {} {} {}",
124+
username.bold(),
125+
format!("({})", id).dimmed(),
126+
format!("created {}", created_at.format("%Y-%m-%d")).dimmed()
127+
);
128+
}
129+
130+
Ok(())
131+
}
132+
133+
async fn cmd_get(pool: &PgPool, username: &str) -> Result<(), UsersError> {
134+
let row = sqlx::query(
135+
r#"
136+
SELECT id, username, created_at, updated_at
137+
FROM users
138+
WHERE username = $1
139+
"#,
140+
)
141+
.bind(username)
142+
.fetch_optional(pool)
143+
.await?
144+
.ok_or_else(|| UsersError::UserNotFound(username.to_string()))?;
145+
146+
let id: uuid::Uuid = row.get("id");
147+
let username: String = row.get("username");
148+
let created_at: chrono::NaiveDateTime = row.get("created_at");
149+
let updated_at: chrono::NaiveDateTime = row.get("updated_at");
150+
151+
println!("{:12} {}", "Username:".dimmed(), username.bold());
152+
println!("{:12} {}", "ID:".dimmed(), id);
153+
println!(
154+
"{:12} {}",
155+
"Created:".dimmed(),
156+
created_at.format("%Y-%m-%d %H:%M:%S")
157+
);
158+
println!(
159+
"{:12} {}",
160+
"Updated:".dimmed(),
161+
updated_at.format("%Y-%m-%d %H:%M:%S")
162+
);
163+
164+
Ok(())
165+
}
166+
167+
async fn cmd_create(pool: &PgPool, username: String) -> Result<(), UsersError> {
168+
// Check if user already exists
169+
let exists: bool =
170+
sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE username = $1)")
171+
.bind(&username)
172+
.fetch_one(pool)
173+
.await?;
174+
175+
if exists {
176+
return Err(UsersError::UserExists(username));
177+
}
178+
179+
// Insert new user
180+
let row = sqlx::query(
181+
r#"
182+
INSERT INTO users (username)
183+
VALUES ($1)
184+
RETURNING id, username, created_at
185+
"#,
186+
)
187+
.bind(&username)
188+
.fetch_one(pool)
189+
.await?;
190+
191+
let id: uuid::Uuid = row.get("id");
192+
193+
println!(
194+
"{} User '{}' created (ID: {}).",
195+
"Created".green().bold(),
196+
username.bold(),
197+
id.to_string().dimmed()
198+
);
199+
200+
println!(
201+
"\n{} Set this user as default with:",
202+
"Next:".cyan().bold()
203+
);
204+
println!(
205+
" {}",
206+
format!("ow alias set <alias> --db <url> --user {}", username).cyan()
207+
);
208+
209+
Ok(())
210+
}
211+
212+
async fn cmd_delete(pool: &PgPool, username: &str) -> Result<(), UsersError> {
213+
let result = sqlx::query("DELETE FROM users WHERE username = $1")
214+
.bind(username)
215+
.execute(pool)
216+
.await?;
217+
218+
if result.rows_affected() == 0 {
219+
return Err(UsersError::UserNotFound(username.to_string()));
220+
}
221+
222+
println!(
223+
"{} User '{}' deleted.",
224+
"Deleted".red().bold(),
225+
username.bold()
226+
);
227+
228+
Ok(())
229+
}

src/main.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use commands::env::EnvCommand;
1616
use commands::kv::KvCommand;
1717
use commands::migrate::MigrateCommand;
1818
use commands::storage::StorageCommand;
19+
use commands::users::UsersCommand;
1920
use commands::workers::WorkersCommand;
2021
use config::{AliasConfig, Config, PlatformStorageConfig};
2122

@@ -26,6 +27,12 @@ const EXAMPLES: &str = color_print::cstr!(
2627
ow workers create my-api <dim>Create a new worker</dim>
2728
ow workers deploy my-api worker.ts <dim>Deploy code to worker</dim>
2829
30+
<dim># Self-hosting (database setup)</dim>
31+
ow alias set local --db postgres://... <dim>Configure DB connection</dim>
32+
ow local migrate run <dim>Run migrations</dim>
33+
ow local users create admin <dim>Create first user (bootstrap)</dim>
34+
ow alias set local --db postgres://... --user admin <dim>Set user context</dim>
35+
2936
<dim># Environment and bindings</dim>
3037
ow env create prod <dim>Create an environment</dim>
3138
ow kv create cache <dim>Create a KV namespace</dim>
@@ -90,6 +97,20 @@ enum Commands {
9097
command: MigrateCommand,
9198
},
9299

100+
/// Manage users (requires db alias, no user context needed for create)
101+
#[command(
102+
visible_alias = "u",
103+
alias = "user",
104+
after_help = "Examples:\n \
105+
ow local users list List all users\n \
106+
ow local users create admin Create user (bootstrap mode)\n \
107+
ow local users get admin Show user details"
108+
)]
109+
Users {
110+
#[command(subcommand)]
111+
command: UsersCommand,
112+
},
113+
93114
/// Create, deploy, and manage workers
94115
#[command(
95116
visible_alias = "w",
@@ -218,19 +239,22 @@ fn extract_alias_from_args() -> (Option<String>, Vec<String>) {
218239
"alias",
219240
"login",
220241
"migrate",
242+
"users",
221243
"workers",
222244
"env",
223245
"storage",
224246
"kv",
225247
"databases",
226248
"setup-storage",
227249
// Short aliases
250+
"u",
228251
"w",
229252
"e",
230253
"s",
231254
"k",
232255
"d",
233256
// Singular/plural variants (for flexibility)
257+
"user",
234258
"worker",
235259
"envs",
236260
"environment",
@@ -514,6 +538,7 @@ async fn main() {
514538
commands::login::run(&alias_name).map_err(|e| e.to_string())
515539
})(),
516540
Commands::Migrate { command } => command.run(alias).await.map_err(|e| e.to_string()),
541+
Commands::Users { command } => command.run(alias).await.map_err(|e| e.to_string()),
517542
Commands::Workers { command } => run_workers_command(alias, command).await,
518543
Commands::Env { command } => run_env_command(alias, command).await,
519544
Commands::Storage { command } => run_storage_command(alias, command).await,

0 commit comments

Comments
 (0)