Admin CLI
Overview
Thecrewship admin command group is the operator-on-the-host recovery surface. Every subcommand here runs a direct write against the local SQLite database, intentionally bypassing the HTTP API. The model is the same one GitLab (gitlab-rake gitlab:password:reset), Gitea (gitea admin user change-password), Nextcloud (occ user:resetpassword) and Mattermost (mmctl user change-password) all adopt: shell access to the host is the credential. If you can ssh to the box, you are the admin — no second authentication factor would be meaningful, since you already control the data directory and could rewrite the database manually anyway.
The flow is deliberately non-circular. The most important caller is an admin whose account is locked out and whose server may or may not be running — routing recovery through the same server they’re recovering would be a chicken-and-egg failure. crewship admin therefore opens ~/.crewship/crewship.db directly (or whatever CREWSHIP_DATA_DIR / DATABASE_URL points at), runs the requested SQL — the mutating commands in a transaction — and exits. The HTTP layer never sees the request and the auth middleware never runs.
Five verbs ship today, all in cmd/crewship/cmd_admin.go. reset-password mints a fresh bcrypt hash for a user (interactive prompt by default; --password for scripted recovery; --password-stdin for the leak-free CI pattern that mirrors docker login --password-stdin) and revokes every active session in the same transaction so a leaked cookie can’t outlive the recovery. invalidate-sessions is the same session-revoke without a password rotation — use when the password is believed safe but a session token is suspected leaked. list-users dumps every row of the users table to a tab-aligned table, flags currently-locked-out accounts with a footer count, accepts --locked-only to filter, and shows the per-account failed_login_count. promote rewrites a user’s workspace role (OWNER / ADMIN / MANAGER / MEMBER / VIEWER), accepting --workspace=<slug> or defaulting to the user’s single workspace when there’s exactly one. sessions list is a forensic read-only dump of every user_sessions row for --email=<email> (--active-only to filter, --limit to cap) — the read side paired with invalidate-sessions. Each opens the DB with a 30s busy_timeout, so a write that races the running server serializes rather than failing instantly; still, stop the server first to avoid a half-applied change.
When to use it
Reach forcrewship admin only when the in-band recovery paths can’t apply. For everyday user management — invites, role changes on a healthy server, password rotation from inside the app — the web UI is the right surface; bypassing it leaves the audit trail thinner and risks racing the running server. The narrow set of legitimate uses:
- You can’t log in at all. Forgot password, sole owner of the workspace, mailer not configured, OAuth provider misconfigured — any combination where the web
/forgotflow won’t deliver a reset link.crewship admin reset-password --email=…mints a new hash directly. - The mailer is unreachable or never set up. Fresh self-hosted installs without
RESEND_API_KEYget a deliberatemailer.Disabled—/forgotstill returns 200 (no enumeration) but no email goes out. Use the CLI instead of standing up SMTP just to recover one account. - CI / unattended provisioning needs a seeded admin. A Dockerfile that bakes a baseline workspace can run
crewship admin reset-password --email=admin@example.com --password=$BOOTSTRAP_PASSWORDnon-interactively against the freshly migrated database, then start the server. The HTTP API isn’t available yet at that point, so this is the only path. - You need to verify which email actually exists. Operators routinely guess at email casing (
Admin@…vsadmin@…) before a reset.crewship admin list-usersdumps the table so the reset command targets the right row instead of silently failing. - A botched RBAC change locked everyone out of a workspace. If a
promotefrom the UI accidentally demoted the lastOWNER, the UI can no longer fix it (noOWNERleft to call the endpoint). Re-promoting from the CLI is the unblock. - You suspect a session token leak but the password is fine. Stolen laptop recovered, browser history dumped to a chat, a tester pasted a session cookie in a screenshot —
crewship admin invalidate-sessions --email=…force-logs the user from every device without forcing a password rotation. The user can log back in normally on the next attempt; only the cached cookies on the lost devices stop working. - Periodic compliance sweep. Audit guidance that says “log every operator out at the end of the quarter” maps to a one-shot
invalidate-sessionsper user; same audit row shape as the leaked-token response (revoked_reason='admin_invalidate').
OWNER, prefer the corresponding HTTP endpoint or settings page — every admin-CLI call is one more uncontrolled DB write to keep out of the audit story.
Key concepts
Usage
Every admin subcommand expects access to the same SQLite file the server uses. Stop the server first — concurrent writes from two processes against the same DB are the failure mode this guard exists to prevent.1. Stop the server (if running)
busy_timeout, so a write that races a running server will wait rather than fail outright. Stop the server anyway — a half-applied change racing the server’s own connection is exactly the failure mode to avoid.
2. Verify the user exists
LOCKED column shows one of - (no lockout), LOCKED until <ts> (currently locked — the account can’t log in), or expired <ts> (had a lockout that has cooled down). The FAILS column is the per-account failed_login_count the brute-force protection increments on each bad credential attempt. The footer appears only when at least one account is actively locked.
To filter to just the currently-locked accounts:
--email argument before querying, so it can only target rows whose stored email is already lowercase. Since web signup stores email verbatim, run list-users first to confirm the row exists in lowercase form; if the row you want isn’t matched, reset-password fails loudly rather than silently creating a new one.
3. Reset a password
Interactive (preferred — the password is not logged anywhere):~/.bash_history nor in ps. The leak-free pattern is --password-stdin:
--password-stdin reads stdin verbatim (preserving embedded spaces and UTF-8; trims exactly one trailing LF/CRLF). The flag is mutually exclusive with --password — passing both is a configuration error.
4. Invalidate all sessions WITHOUT changing the password
revoked_reason='admin_invalidate', distinguishing this from password_change (issued by reset-password) and user_logout (the “log out from all devices” button in the UI).
reset-password already revokes sessions as a side effect; reach for invalidate-sessions only when you want JUST the force-logout. The separate verb keeps the audit story clean — a combined --invalidate-sessions-only flag on reset-password would obscure the operator’s intent.
5. Promote (or demote) a workspace role
OWNER, ADMIN, MANAGER, MEMBER, or VIEWER. Note that promote writes the row directly and does not stop you from demoting the last OWNER of a workspace — verify with list-users first if you’re changing an owner.
6. Restart the server
Examples
Locked-out solo admin, no mailer configured
You’re the soleOWNER of a self-hosted Crewship instance. The mailer was never set up (RESEND_API_KEY is unset), /forgot returns 200 but no email goes out, and you’ve forgotten your password. SSH to the host:
sudo -u crewship matters: the data directory is owned by the service account, so running the admin CLI as root would create root-owned WAL files that confuse the next server start.
CI bootstrap of a freshly migrated database
A Dockerfile that bakes a baseline workspace for a demo deployment:docker buildx build --secret id=bootstrap_password,src=./bootstrap.pw … — the secret file is mounted only for the duration of that one RUN step and never becomes part of any image layer. Never use plain ARG BOOTSTRAP_PASSWORD: build args land in image history and are recoverable via docker history <image> for anyone who pulls the resulting image.
The HTTP API isn’t running during RUN steps — crewship admin is the only path that can produce a logged-in-able user before the server boots for the first time.
Restoring OWNER after a botched demotion
A MANAGER accidentally clicked “Demote to ADMIN” on the only OWNER of the marketing workspace. The web UI now refuses every elevation request from that user (no OWNER left to authorise it). Recovery on the host:
API reference
The admin CLI is deliberately not exposed as an HTTP API — that’s the whole point of the surface. Every other Crewship feature has a/api-reference/<thing> page; this one does not. Routing recovery through the same server you’re recovering would be circular, and exposing these writes over HTTP would create a parallel auth surface to harden when the existing one already covers every healthy-system case.
The full source of truth is one file: cmd/crewship/cmd_admin.go (~814 lines). It’s small enough to read top-to-bottom; the test file cmd_admin_test.go covers the same surface.
The SQL surface area each subcommand touches:
None of these writes append to
journal_entries — they bypass the HTTP audit middleware entirely. If you need the change traced, record it out of band.
For the user/workspace data model these commands operate on, see the Admin API reference — the underlying schemas are the same; the admin CLI just bypasses the HTTP layer that normally fronts them.
Common pitfalls
- Don’t run as
rootagainst a service-owned data directory. Ifcrewshipdruns as acrewshipsystem user, the data directory is owned by that account. Runningcrewship adminasrootcreates root-owned WAL / journal files; on next server start,crewshipdcan’t write them and the server bails with a permissions error. Alwayssudo -u crewship crewship admin …. - Stop the server first, every time. Two processes writing the same SQLite file is the failure mode to avoid. The admin CLI does not lock the server out — it opens the DB with a 30s
busy_timeout, so a racing write just waits — but a half-applied change interleaved with the server’s own writes is still a partially-applied-state risk. Stop the server, then run the command. - Take a backup before any write. There is no undo for
reset-passwordorpromote. Acrewship backup create(or just a copy ofcrewship.db) before the recovery makes a botched call a one-command rollback instead of a multi-hour incident. - The admin CLI lowercases
--email. Every admin subcommand runsstrings.ToLoweron the--emailargument before querying. Web signup stores emails as typed, so a user who registered asAdmin@example.comcannot be targeted by these commands (the lowercasedadmin@example.comwon’t match the stored row). Runlist-usersfirst: if the row you need shows mixed case, fix it through the app, not the CLI. reset-passwordDOES invalidate active sessions. Every active session for the user is revoked in the same transaction as the password change (revoked_reason='password_change'), so a leaked cookie can’t outlive the recovery. No server restart needed — the next request on the old cookie returns 401. If you want force-logout WITHOUT changing the password (suspected token leak but the password is believed safe), usecrewship admin invalidate-sessionsinstead.- Don’t put the password in shell history.
--password='…'lands in~/.bash_historyand is visible inpsto anyone with shell access during the window the command runs. Prefer the interactive prompt; for scripted use, pass via--password="$VAR"whereVARis sourced from an env file or BuildKit secret mount. CREWSHIP_DATA_DIRoverrides~/.crewship. On a host where the service was started with a custom data dir (common on dev VMs), runningcrewship adminwith default env points at the wrong empty database, silently fails to find the user, and prints a misleading “no such email” error. Export the sameCREWSHIP_DATA_DIRthe service uses (there is no--data-dirflag). If the service was configured with an explicitDATABASE_URLinstead, set that — it takes precedence overCREWSHIP_DATA_DIRin the admin CLI’s resolution.- Migration version conflicts can hide your write. If the data directory predates the migration that added the column you’re updating (e.g.
password_updated_at), the write succeeds but the new column stays at its default. Migrations apply automatically oncrewship start, so start the server once against an older database (letting it migrate) before runningcrewship adminagainst it.
Related
- Authentication — the in-band recovery flow (email-based password reset) for users without shell access.
- Backup — what to do before running admin commands against a production database.
- Installation — where
CREWSHIP_DATA_DIRis configured and the SQLite file lives.