feat: add create-user cli

This commit is contained in:
2026-04-20 17:18:54 +02:00
parent 15cb3ea556
commit 45ce8c1aa4
4 changed files with 152 additions and 134 deletions

View File

@@ -27,8 +27,9 @@ RUN bun run build:assets
# Generate templ files
RUN templ generate
# Build the server and the CLI tools
# Build the server and CLI tools
RUN go build -o main_server ./cmd/server
RUN go build -o create_user ./cmd/create-user
FROM debian:bullseye-slim
@@ -41,6 +42,7 @@ RUN apt-get update && apt-get install -y ca-certificates sqlite3 && rm -rf /var/
RUN mkdir -p /app/data
COPY --from=builder /app/main_server .
COPY --from=builder /app/create_user .
COPY --from=builder /app/static ./static
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/migrations ./migrations

View File

@@ -3,7 +3,8 @@
Executable entrypoints live here.
- `cmd/server`: main web process (`go run ./cmd/server`)
- `cmd/create-user`: admin CLI for adding login users (`go run ./cmd/create-user --email user@example.com --password-stdin`)
## Why this structure
I wanted to keep the repository root clean and focused on project metadata like `README.md`, `go.mod`, and `Dockerfile`. Keeping entrypoints under `cmd/` also makes it easy to add more binaries later without cluttering the root, and it matches standard Go conventions for projects that grow beyond a single binary.
I wanted to keep the repository root clean and focused on project metadata like `README.md`, `go.mod`, and `Dockerfile`. Keeping entrypoints under `cmd/` also makes it easy to add more binaries later without cluttering the root, and it matches standard Go conventions for projects that grow beyond a single binary.

146
cmd/create-user/main.go Normal file
View File

@@ -0,0 +1,146 @@
package main
import (
"database/sql"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"
"github.com/google/uuid"
sqlite3 "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
)
const bcryptCost = 12
type config struct {
email string
password string
passwordStdin bool
dbFile string
}
func trimTrailingNewline(v string) string {
v = strings.TrimSuffix(v, "\n")
v = strings.TrimSuffix(v, "\r")
return v
}
func parseConfig() (config, error) {
var cfg config
flag.StringVar(&cfg.email, "email", "", "User email/username")
flag.StringVar(&cfg.password, "password", "", "User password")
flag.BoolVar(&cfg.passwordStdin, "password-stdin", false, "Read password from stdin")
flag.StringVar(&cfg.dbFile, "db", "", "SQLite database file path")
flag.Parse()
args := flag.Args()
if len(args) > 2 {
return cfg, fmt.Errorf("too many arguments")
}
if cfg.email == "" && len(args) >= 1 {
cfg.email = args[0]
}
if cfg.password == "" && len(args) == 2 {
cfg.password = args[1]
}
if cfg.password != "" && cfg.passwordStdin {
return cfg, fmt.Errorf("use either --password or --password-stdin")
}
cfg.email = strings.TrimSpace(cfg.email)
if cfg.email == "" {
return cfg, fmt.Errorf("email is required")
}
if cfg.passwordStdin {
passwordBytes, err := io.ReadAll(os.Stdin)
if err != nil {
return cfg, fmt.Errorf("read password from stdin: %w", err)
}
cfg.password = trimTrailingNewline(string(passwordBytes))
}
if cfg.password == "" {
return cfg, fmt.Errorf("password is required")
}
if cfg.dbFile == "" {
cfg.dbFile = os.Getenv("DATABASE_FILE")
}
if cfg.dbFile == "" {
cfg.dbFile = "mal.db"
}
if _, err := os.Stat(cfg.dbFile); err != nil {
if errors.Is(err, os.ErrNotExist) {
return cfg, fmt.Errorf("database file not found: %s", cfg.dbFile)
}
return cfg, fmt.Errorf("inspect database file: %w", err)
}
return cfg, nil
}
func run() error {
cfg, err := parseConfig()
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.password), bcryptCost)
if err != nil {
return fmt.Errorf("hash password: %w", err)
}
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", cfg.dbFile))
if err != nil {
return fmt.Errorf("open database: %w", err)
}
defer db.Close()
_, err = db.Exec(
"INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)",
uuid.NewString(),
cfg.email,
string(hash),
)
if err != nil {
var sqliteErr sqlite3.Error
if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintUnique {
return fmt.Errorf("user already exists: %s", cfg.email)
}
return fmt.Errorf("insert user: %w", err)
}
fmt.Printf("created user: %s\n", cfg.email)
return nil
}
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [--db path] [--email email] [--password value | --password-stdin] [email] [password]\n", os.Args[0])
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintf(os.Stderr, " %s --db /app/data/mal.db --email admin@example.com --password 'strong-pass'\n", os.Args[0])
fmt.Fprintf(os.Stderr, " printf 'strong-pass' | %s --email admin@example.com --password-stdin\n", os.Args[0])
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "Defaults:")
fmt.Fprintln(os.Stderr, " --db uses DATABASE_FILE, then mal.db")
flag.PrintDefaults()
}
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
flag.Usage()
os.Exit(1)
}
}

View File

@@ -2,35 +2,8 @@
set -euo pipefail
usage() {
cat <<'EOF'
Usage:
./scripts/create-user.sh <email> <password>
Creates a user directly in the SQLite database so they can log in.
Environment:
DATABASE_FILE SQLite database path (default: <repo>/mal.db)
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
if [[ $# -gt 2 ]]; then
usage >&2
exit 1
fi
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
repo_root="$(cd -- "${script_dir}/.." >/dev/null 2>&1 && pwd)"
db_file="${DATABASE_FILE:-${repo_root}/mal.db}"
if [[ "${db_file}" != /* ]]; then
db_file="$(pwd)/${db_file}"
fi
email="${1:-}"
password="${2:-}"
@@ -49,111 +22,7 @@ if [[ -z "${email}" || -z "${password}" ]]; then
exit 1
fi
if [[ ! -f "${db_file}" ]]; then
printf 'Database file not found: %s\n' "${db_file}" >&2
printf 'Run the server once to apply migrations, or set DATABASE_FILE.\n' >&2
exit 1
fi
tmp_dir="${repo_root}/tmp"
mkdir -p "${tmp_dir}"
tmp_go="$(mktemp "${tmp_dir}/create-user.XXXXXX.go")"
trap 'rm -f "$tmp_go"' EXIT
cat >"${tmp_go}" <<'EOF'
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"io"
"os"
"strings"
_ "github.com/mattn/go-sqlite3"
"golang.org/x/crypto/bcrypt"
)
const bcryptCost = 12
func randomID() (string, error) {
id := make([]byte, 16)
if _, err := rand.Read(id); err != nil {
return "", fmt.Errorf("generate id: %w", err)
}
return hex.EncodeToString(id), nil
}
func trimTrailingNewline(v string) string {
v = strings.TrimSuffix(v, "\n")
v = strings.TrimSuffix(v, "\r")
return v
}
func main() {
if len(os.Args) != 3 {
fmt.Fprintln(os.Stderr, "usage: go run create-user.go <db-file> <email>")
os.Exit(2)
}
dbFile := os.Args[1]
email := strings.TrimSpace(os.Args[2])
passwordBytes, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to read password: %v\n", err)
os.Exit(1)
}
password := trimTrailingNewline(string(passwordBytes))
if email == "" || password == "" {
fmt.Fprintln(os.Stderr, "email and password are required")
os.Exit(1)
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to hash password: %v\n", err)
os.Exit(1)
}
userID, err := randomID()
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create user id: %v\n", err)
os.Exit(1)
}
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_foreign_keys=on", dbFile))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open database: %v\n", err)
os.Exit(1)
}
defer db.Close()
_, err = db.Exec(
"INSERT INTO user (id, username, password_hash) VALUES (?, ?, ?)",
userID,
email,
string(hash),
)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed: user.username") {
fmt.Fprintf(os.Stderr, "user already exists: %s\n", email)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "failed to insert user: %v\n", err)
os.Exit(1)
}
fmt.Printf("created user: %s\n", email)
}
EOF
(
cd "${repo_root}"
printf '%s' "${password}" | go run "${tmp_go}" "${db_file}" "${email}"
printf '%s' "${password}" | go run ./cmd/create-user --email "${email}" --password-stdin
)