Skip to main content

crewship admin

cmd/crewship/cmd_admin.go exposes the operator-on-the-host recovery surface. The write subcommands under admin open the SQLite database file on this machine directly (no HTTP, no auth token) — the server doesn’t even need to be running. Use these when a user is locked out of the UI. The “credential” for those commands is shell access to the host. That matches what GitLab (gitlab-rake gitlab:password:reset), Gitea (gitea admin user change-password), Nextcloud (occ user:resetpassword), and Mattermost (mmctl user change-password) all do — if you can SSH to the box, you ARE the admin.

Which database am I talking to?

admin list-users reads the server the CLI targets. Everything else in the local-only group reads or writes the database file on this host. Those are two different things, and until #2086 the CLI conflated them: it resolved ~/.crewship/crewship.db and ignored --server / CREWSHIP_SERVER / --profile entirely, so pointing the CLI at a populated server and running admin list-users printed
and exited 0. The rule now:
  • list-users uses GET /api/v1/admin/users on the targeted server: the current workspace’s members, or every account for the instance owner. It needs a login — always, not only when something names a server. It is the one verb here that is not gated: it branches on --local alone, so on a host with no CLI config and no CREWSHIP_SERVER it still goes to the server and still fails with “not logged in”, where the pre-#2086 build printed the table. Deliberate — a fallback to the local file on a failed login would answer the server’s question from a file that may belong to another instance. --local is the old behaviour, asked for explicitly, and both failure paths say so.
  • Every other local-only subcommand refuses to run when a server is named, unless you pass --local. The refusal names the server, names the file it would otherwise have touched, and names the flag. With nothing naming a server they run unchanged.
  • A server on localhost is not an exemption. crewshipd routinely runs with its own DATABASE_URL — every dev clone uses file:./crewship.db — so “same host” tells you nothing about “same database”.
The credential for the local-only commands is shell access to the host — there is no crewship login token. Requirements:
  • Read+write access to the database file: DATABASE_URL when set, otherwise <data dir>/crewship.db (default ~/.crewship).
  • The crewship binary must run on the same host as that file.
  • The host’s filesystem permissions are the authentication.
--local is what tells the CLI you mean that file rather than the server it is configured for. Every command prints the path it resolved.

Subcommands

crewship admin reencrypt

Re-encrypt stored secret envelopes to the server’s current master-key version. This is the server-side step after configuring a new key version; the command requires authentication, an active workspace, and a running server.
The operation is idempotent: envelopes already at the current version are skipped. Human output reports the target key_version, totals for Re-encrypted, Skipped, and Failed, followed by per-table/per-column counts when present. JSON output contains key_version, reencrypted, skipped, failed, and columns. A non-zero exit means at least one value could not be decrypted with the configured keys; do not retire needed keys. Even with Failed: 0, keep the exact original ENCRYPTION_KEY: journal and instance-audit MACs retain this v1 root. Re-encryption does not re-sign history.

crewship admin reset-password

Rotate a user’s password.
The hashed value is written into users.hashed_password, the brute-force lockout state is cleared (failed_login_count=0, locked_until=NULL), and every active session row for that user is revoked as a side effect (the audit trail records revoked_reason='password_change').
Omit both --password and --password-stdin to get an interactive prompt with hidden input — the right default for ad-hoc recovery from a host shell, and the only form that never leaks to shell history or ps.

crewship admin list-users

Two different answers, deliberately: Default — the server. GET /api/v1/admin/users on the server the CLI targets (ADMIN+): the members of the current workspace, or — for an instance administrator — every account on the instance. Columns: EMAIL, NAME, CREATED, ROLE (role@workspace-slug, comma-joined), LAST ACTIVE, SESSIONS (signed-in devices), LOCKED (until … while a lockout is in force). Requires a login. --local — the database file on this host. Every row in users ordered by created_at ASC, across every workspace. Columns: EMAIL, NAME, CREATED, LOCKED (derived from locked_until), FAILS (failed_login_count), ROLES (role@workspace-slug, comma-joined across workspaces). Needs no server — the path for when the server or the login is what is broken.
To lift a lock from the terminal: crewship admin user unlock <email> (over HTTP), or reset-password --local on the database host. Notes go to stderr: which people the list covers on the server path, and the “which database” note on the --local path. Stdout carries the table and nothing else, so crewship admin list-users | awk 'NR>1 {print $1}' yields addresses. That holds for an empty workspace too: stdout is the header row alone and (no users in this workspace) is printed on stderr, so the same pipe yields nothing rather than a line of prose. (The --local listing keeps its own (no users — run 'crewship seed' …) on stdout — it is the example output shown in the admin CLI guide.)

crewship admin promote

Grant a workspace role to an existing user. Roles ascend VIEWER < MEMBER < MANAGER < ADMIN < OWNER; the command updates the user’s existing row in workspace_members (the user must already be a member — a non-member errors with user is not a member of workspace).
This is not a duplicate of crewship workspace member role, which is the server-side way to do the same thing. That path enforces the role ladder against your role and needs a session — so it cannot mint the first OWNER, and it is unavailable to the operator who is locked out. When the CLI names a server, admin promote refuses and points at both options. This is the post-init step for bootstrapping a second admin: the first user from crewship init is OWNER automatically; everyone else starts as a regular member and needs admin promote to gain elevated access without going through the UI.

crewship admin invalidate-sessions

Force-logout every active session for one user without changing their password. The user can still sign in normally afterwards — they just have to re-authenticate on every device they were already on.
Each revoked row is stamped revoked_reason='admin_invalidate' so the audit trail distinguishes this from the side-effect revokes that fire during reset-password (those carry revoked_reason='password_change').
  • Laptop stolen / recovered, the password is believed safe but a cached cookie might still be on the device.
  • Suspected token leak via Slack screenshot, browser history dump, etc.
  • Periodic compliance sweep — “log everyone out of yesterday’s sessions”.

crewship admin sessions list

Forensic read of user_sessions for one user — mirrors crewship session list but for arbitrary users (the user-side command is self-only). Admin-only via direct DB access.
Output columns: ID, STATUS (derived from revoked_at + expires_at; revoked rows show revoked:<reason>), CREATED, LAST USED (last_used_at), EXPIRES, IP, UA (user_agent). Sorted created_at DESC. A typo’d email reports no user with email <addr> rather than silently returning zero rows. Pair with admin invalidate-sessions to act on what sessions list surfaces.

crewship admin prune-legacy

Removes orphaned pre-C1 (slug-only) crew docker resources — volumes and containers named <prefix>-{home,tools,team}-<slug> (e.g. crewship-3-tools-engineering) left over from before the C1 naming change (2026-06 audit, which re-keyed crew resources to include the crew id).
Unlike the rest of crewship admin, this command is HTTP-backed. The docker daemon lives behind the running server, not the local SQLite DB, so this command needs a reachable server and an authenticated session (crewship login) — it is not a host-only recovery command. It is filed here because it is an operator/admin maintenance action.

Why this exists

A legacy slug-only volume survives crewship seed --nuke: the nuke clears the database, and crew teardown removes the id-scoped volumes — but never the orphaned slug-only ones, because no crew row references them. While they exist, the runtime’s legacy-resource guard refuses to start the crew’s container, and the failure reaches users only as a generic “failed to start agent container”. Every agent in the affected crew fails; a seed --smoke-test reports N/N agents failed. crewship doctor surfaces this proactively: the legacy crew resources check calls the authenticated GET /api/v1/admin/legacy-resources endpoint and WARNs with this command as the remediation when orphaned resources are present. (Detection runs on this admin endpoint, not on the unauthenticated /healthz hot path, so a slow docker daemon can never stall health probes.) The prune is instance-wide: legacy docker names carry no workspace or crew id, so detection (what crewship doctor surfaces) and prune enumerate the same full crew set — otherwise the doctor could WARN on a slug the prune can never reach. It removes only the orphaned legacy names; the id-scoped resources the live runtime uses are excluded (slug/id-collision safe) and never touched. It reports each resource removed:
Requires the OWNER or ADMIN role. Returns 503 when the server’s container provider is not docker (nothing to prune).

crewship admin reap-orphan-containers

Finds — and, with --apply, reaps — crew containers orphaned by an internal-token master rotation. When crewshipd restarts with a different internal-token master while a per-crew agent container survives, that container keeps the crew-bound token (crwv1.<ws>.<crew>.<mac>) it was minted with, and the new process rejects it forever:
The container’s credential sync is silently and permanently broken while docker ps still shows it “healthy”, and it re-emits the warning every reap interval (log-noise that drowns real signal). This command clears those orphans.
HTTP-backed, like prune-legacy: the docker daemon lives behind the running server, so this needs a reachable server and an authenticated session (crewship login). Dry-run by default — nothing is removed unless you pass --apply.

Reading the result: “none found” vs “could not tell”

The detector compares the token fingerprint a container’s sidecar advertises on /health against the one the server would mint for that crew today. It fails safe: a container that advertises no fingerprint is never classified as an orphan, so a reap only ever removes something positively proven stale. That safety has a corollary — a sidecar too old to advertise token_fp at all makes the detector structurally unable to find anything, while still reporting zero orphans. So the sweep also reports its own coverage:
The third line is a third state, not a variant of the first. It is what you get on a workspace whose crews are all idle (and on a CI runner that has a docker daemon but never started a crew container): the sweep is clean in the sense that nothing was reaped, but it says nothing about whether the classifier would have got the answer right, because it never classified anything. An inert detector is a deploy defect, not a workspace condition: the slot’s sidecar binary was never re-pointed to the freshly built artifact on reconcile (#1390). Fix the slot’s sidecar, then re-run. Partial coverage (some sidecars silent) is reported as a count rather than rounded away. --format json carries inspected, identified and detector_inert. A server predating these fields omits them, and the CLI then falls back to the older, non-committal wording rather than inferring a coverage figure it wasn’t given.

One-time step after upgrading to the stable-master build

The stable internal-token master (#1385) stops future restarts from ever orphaning a container. But the deploy that first introduces it rotates the master once — from the old per-boot random to the value derived from ENCRYPTION_KEY. Any container that outlived that one deploy is orphaned exactly once and does not self-heal. After rolling out the new build, do one of:
  • Recreate the surviving containers — a normal redeploy / crew re-dispatch starts a fresh container that re-mints a valid token; or
  • Run crewship admin reap-orphan-containers --apply to stop+remove just the orphaned ones (the next dispatch to each crew recreates them fresh).
From then on the master is stable and no further action is needed unless you deliberately rotate CREWSHIP_INTERNAL_TOKEN or the ENCRYPTION_KEY it derives from.

How detection stays safe

For each of the workspace’s crews the command probes the running container’s sidecar /health for the token fingerprint it advertises (token_fp, #1385) and compares it against the fingerprint of the token the server would mint today. A container is listed only on a definite, non-empty mismatch — an unreachable sidecar, a pre-#1385 sidecar that reports no fingerprint, or an unconfigured master are all treated as unknown and are never reaped. A healthy container is never touched. Any agent inside an orphaned container is already broken (its credentials can’t sync), so recreation restores it rather than interrupting healthy work. Dry-run prints the orphans it found; --apply reports what it reaped:
Requires the OWNER or ADMIN role. Returns 503 when the server’s container provider is not docker.

See also

  • crewship init — first-user bootstrap on a fresh database. After init, admin promote is how you add a second OWNER.
  • crewship doctor — the legacy crew resources check WARNs when a prune is needed.
  • crewship session — user-scoped self-service of the same user_sessions table.
  • crewship doctor — adjacent host-side diagnostics (data dir, schema version, container runtime).

crewship admin health

Disk is the data directory’s volume — the one that fills in practice, since the database, agent outputs and logs all live under it. Where the platform cannot measure it, the row says so rather than printing a zero. Encryption key reports where ENCRYPTION_KEY came from. generated means Crewship auto-created it into <dataDir>/secrets.env, next to the database it protects — so a copied disk, a restored snapshot or a stray backup carries the ciphertext and what opens it. external means the operator injected it. Log level shows the live level, and says (temporary) when a timed override is in effect — a level that reverts on its own is a different fact from one someone set for good.

crewship admin stats

Current open-source releases permit unlimited crews, members and agents, so these counts have no license ceiling. Future releases may introduce resource licensing; the CLI retains support for displaying finite ceilings. If the license cannot be read, counts still print.

crewship admin workspaces

GET /api/v1/admin/workspaces. A workspace ADMIN or OWNER gets one row, the workspace the CLI is authenticated against. An instance administrator (see crewship admin instance) gets every workspace on the instance; * marks the current one. KIND is team or personal (created at sign-up for one person; update-workspace changes it). RUNS 7D counts runs started in the last seven UTC days, SPEND 30D is the estimated spend over the last 30 days, LAST ACTIVITY is the newest audit-log entry. A server older than those fields prints - for them. Requires OWNER or ADMIN (canRole "manage").

crewship admin user

Per-person actions on the server the CLI targets. People are named by email; the id is resolved through GET /api/v1/admin/users, so you can act on the members of your current workspace, or on any account if you are an instance administrator. An ADMIN cannot act on an OWNER.
The host-side invalidate-sessions and sessions list stay for when the server or the login is what is broken; these are the same actions over HTTP, for an admin who is signed in. Every action writes an audit-log entry.

crewship admin instance

What an instance administrator does across the whole server. People are named by email and workspaces by slug. Only an instance administrator may run these: CREWSHIP_OWNER_EMAIL on the server, or anyone named with add-admin. An install that names nobody names the owners of its oldest workspace once, the first time it is asked, and never again. Being ADMIN or OWNER of a workspace is not enough. Every action is recorded in the instance audit. The last active instance administrator cannot be removed or suspended. These commands need no workspace selected (crewship workspace use): an instance administrator may belong to none.

add-person <email>

POST /api/v1/admin/instance/people. Creates the account, puts it in the workspaces you name and prints a setup link valid for 7 days. No email is sent; pass the link on yourself. The person chooses their own password.

grant <email>

PUT /api/v1/admin/instance/workspaces/{workspaceId}/members/{userId}. Gives access to a workspace or changes the role there. OWNER is allowed; the last owner of a workspace cannot be moved down, use transfer instead.

revoke <email>

DELETE /api/v1/admin/instance/workspaces/{workspaceId}/members/{userId}. Takes the person out of the workspace. Their pages move first, as when a workspace admin removes them. The last owner stays.

create-workspace <name>

POST /api/v1/admin/instance/workspaces. Creates an empty workspace owned by --owner. You are not added to it.

update-workspace <slug>

PATCH /api/v1/admin/instance/workspaces/{workspaceId}. Renames any workspace, or marks it team (shared) or personal (created at sign-up for one person). You are not added to it, and the slug stays. Give at least one of them.

transfer <workspace-slug>

POST /api/v1/admin/instance/workspaces/{workspaceId}/transfer-ownership. Makes a member the owner; every previous owner stays on as ADMIN.

delete-workspace <slug>

DELETE /api/v1/admin/instance/workspaces/{workspaceId}. Deletes the workspace with its crews and agents. The instance audit keeps the record.

suspend <email>

POST /api/v1/admin/instance/people/{userId}/suspend. The account can no longer sign in; every session and CLI token ends now. Workspace access is kept for reactivate. You cannot suspend yourself or the instance owner.

reactivate <email>

POST /api/v1/admin/instance/people/{userId}/reactivate. Lifts a suspension. Ended sessions stay ended; the person signs in again. POST /api/v1/admin/instance/people/{userId}/setup-link prints a new setup link and voids the previous one. Only an account nobody has signed in to gets one; anyone else resets their own password.

add-admin <email>

PUT /api/v1/admin/instance/admins/{userId}. Names an instance administrator. The named admins and CREWSHIP_OWNER_EMAIL are the whole list. When nobody who could run this can sign in, --local writes the database file on the host — the one way back once the last admin is gone. Stop the server first: a running server owns the only database writer, and the command refuses beside it.

remove-admin <email>

DELETE /api/v1/admin/instance/admins/{userId}. Removes a named instance administrator. Nobody removes themselves; CREWSHIP_OWNER_EMAIL is changed in the server’s environment.

audit

GET /api/v1/admin/instance/audit. Instance-level actions, newest first.

keeper governance

GET /api/v1/admin/instance/keeper/governance. What each workspace has switched on: the watchdog, its sampling, the DENY alert threshold, four-eyes approval, the credential lease and the judge. Every workspace is listed, including ones you are not a member of. default in the SOURCE column means the workspace was never set and follows the instance defaults — the last save for all workspaces.

keeper set

PUT /api/v1/admin/instance/keeper/governance. Sets the Keeper watchdog for one, several or all existing workspaces. Only the settings you pass change. For one workspace it saves straight away. For several, or --all, it first lists every workspace whose settings would be overwritten and asks, then saves exactly that: if a workspace was created or a setting changed in between, the server refuses and nothing is written. The save is all or nothing: if one workspace refuses the values, none changes. --all means every existing workspace; what a new workspace starts with is keeper defaults.

keeper defaults

GET | PUT /api/v1/admin/instance/keeper/governance/defaults. The template a new workspace’s Keeper starts with: a workspace copies it when it is created and keeps its copy, so changing the template changes no existing workspace (keeper set --all does that). Without settings it shows the template; with them it shows the change, asks, and saves exactly what it showed.

keeper requests

GET /api/v1/admin/instance/keeper/requests. Keeper decisions across every workspace, newest first: credential requests and the background reviews, each row with its workspace. The last line totals what the filter matches.

keeper health

GET /api/v1/admin/instance/keeper/health. Every workspace’s recent decision window: how many decisions it holds, how many let work go on, how often the judge failed, the p95 latency and any standing alarm. Below the sample minimum there is too little to judge, which is not the same as healthy.

retention get

GET /api/v1/admin/instance/retention. How long every workspace keeps each kind of data: routine runs, approvals, audit log, credential audit, memory versions, page panel data, inbox items, chats and Keeper decisions. A window is a number of days or forever. The last row is what a workspace created now starts with (see retention defaults), followed by the fixed limits the server applies on its own. Use -f json for the full document, including what each window deletes.

retention set

PUT /api/v1/admin/instance/retention. Sets retention windows for one, several or every existing workspace. Only the windows you pass change. Each takes a number of days, 1–3650, or forever; routine runs, memory versions and page panel data always age out and cannot be forever. It always shows first what would change and how many rows the next daily sweep would then delete, and asks. It saves exactly what it showed: if a workspace was created or a window changed in between, the server refuses and nothing is written. The save is all or nothing, and saving a window deletes nothing by itself: the sweep does, at its next run. Backups taken before still hold the rows it removes. --all selects every workspace that exists now; it does not change what a workspace created later starts with, which is retention defaults set.

retention defaults get

GET /api/v1/admin/instance/retention/defaults. The retention windows a workspace created now starts with, one row per window. SOURCE says whether an administrator set it or it is the product default.

retention defaults set

PUT /api/v1/admin/instance/retention/defaults. Changes the retention windows a workspace created later starts with. Only the windows you pass change; each takes a number of days, 1–3650, or forever. No existing workspace changes and nothing is deleted. It shows the change first and asks, and the confirmation saves exactly what it showed: if another admin changed a default in between, the server refuses (409) and nothing is written.

backups bundles

GET /api/v1/admin/instance/backups/bundles. Every catalogued backup bundle on the server, across every workspace — including ones you are not a member of. Each row carries its proof level (checksum: written and its checksum recorded; contents checked; test restore), whether it is pinned, and its gaps: what the bundle should hold and does not — attachment files that were not on disk, memory versions without content, provisioned crews whose container was gone. complete means the bundle recorded no gap; not recorded means it was catalogued before gaps were recorded, which is not the same as complete.

backups pin <path>

POST /api/v1/admin/instance/backups/bundles/pin. Pins a bundle so no retention rule deletes it — not the age rule, not the count floor, not a rotation. The path is the bundle’s path as backups bundles prints it. Written to the instance audit log.

backups unpin <path>

POST /api/v1/admin/instance/backups/bundles/unpin. Removes the pin; retention rules apply to the bundle again. Written to the instance audit log.

backups restores

GET /api/v1/admin/instance/backups/restores. Every restore, dry run and drill the server has run, newest first: who ran it, which bundle, into what, and the result — ok, partial (it landed, but the report names something skipped, missing or lowered: crew files not landed, credential tiers clamped, columns dropped, attachment files missing) or failed. --format json carries each full report.

backups plans list

GET /api/v1/admin/instance/backups/plans. Every backup plan: its preset, scope, when it runs and in which timezone, whether it is switched on, and its next run time. Plans run on the server’s backup scheduler, never through an agent.

backups plans get <id>

GET /api/v1/admin/instance/backups/plans/{id}. One plan in full: scope, contents, schedule, keep rules and busy window.

backups plans create

POST /api/v1/admin/instance/backups/plans. --preset picks the defaults: complete backs up the whole instance with data daily at 03:00 and complete environments weekly; workspace backs up every category of the chosen workspaces daily; custom backs up only the categories in --contents (the server adds what they need to be restorable) and never counts as protecting a workspace. --recipient is required: every backup is encrypted. Flags you do not pass take the preset’s default. Written to the instance audit log.

backups plans update <id>

PUT /api/v1/admin/instance/backups/plans/{id}. Only the flags you pass change, and the next run time is recomputed. Takes the same flags as backups plans create; --disabled=false switches a plan back on. Written to the instance audit log.

backups plans delete <id>

DELETE /api/v1/admin/instance/backups/plans/{id}. Deletes the plan. Its bundles and its run history stay. Written to the instance audit log.

backups plans next <id>

GET /api/v1/admin/instance/backups/plans/{id}/next. The plan’s next run times, and which of them also take complete environments.

backups plans calendar <id>

GET /api/v1/admin/instance/backups/plans/{id}/calendar. Day by day: runs that were done, skipped, caught up or failed, and runs still planned.

backups plans preview

POST /api/v1/admin/instance/backups/plans/preview-contents. What a plan’s backups would carry: the categories included, the ones kept because others need them (memory needs crews and agents, attachment files need chats), and the ones left out.

backups runs

GET /api/v1/admin/instance/backups/runs. Every backup run, newest first: the plan, the trigger (schedule, manual, catchup), the workspace, the status (running, done, incomplete, failed, interrupted, skipped), each phase it went through and the bundle’s proof level.

backups run

POST /api/v1/admin/instance/backups/run. Starts a backup now in the server’s backup service (never through an agent). The server answers with the run id at once and finishes the backup on its own, so the CLI’s timeout cannot cancel it; --wait follows every run it started to the end and prints what each wrote (with --format json, a list of runs in the shape of backups runs). With --plan it runs that plan. Otherwise --scope instance writes one instance bundle — the whole database, every file store and every crew container, copied in one quiet window: running work is drained first, writes are held for the copy only and released before packing — and --scope workspaces writes one bundle per workspace, narrowed by --workspace, with --preset and --contents choosing what goes in. Every backup is encrypted: --recipient (repeatable) or --passphrase-file, not both. Without either, the recipients come from the plan that covers the same scope. The passphrase is held in the server’s memory until the run starts and is never stored.

backups run-status <id>

GET /api/v1/admin/instance/backups/run/{runId}. One run, with the same fields as a row of backups runs: its status (running, done, incomplete, failed, interrupted or skipped), each phase, the bundle it wrote, how long writes were held, and every gap. Exits non-zero when the run failed, was interrupted or was skipped.

backups overview

GET /api/v1/admin/instance/backups/overview. Whether a restore would work. The verdict is the weakest proof among the latest full backups of what you selected — verified, partial, failed, contents_checked, checksum_only or none; custom backups never count. Then what is protected, how often, where, for how long, the last test restore, what needs attention, the last 14 nights and the space backups use and need.

backups vault-keys

GET /api/v1/admin/instance/backups/vault-keys. Every vault key version the database’s sealed values reference or this server has configured, the environment variable it is read from, how many sealed values use it, whether it mints new ones, whether this server holds a key for it, and whether the recovery kit is on. Counts only: no key material is shown.

backups recovery-kit on|off

PUT /api/v1/admin/instance/backups/settings/recovery-kit. With the kit on, every key version the database references rides inside new instance bundles (never workspace or crew bundles), so crewship recover can give the restored server what it needs to read credentials. Whoever holds a private backup key can then read every secret in the instance. Written to the instance audit log.

backups check <path>

POST /api/v1/admin/instance/backups/bundles/check. Proof level 2 — contents checked: the server decrypts the bundle, reads every section to the end and compares table row counts and file digests with the manifest. Nothing is restored. The key is sent for this request only and never stored. Exits non-zero when the check fails.

backups restore-checks <path>

POST /api/v1/admin/instance/backups/restore/checks. What a restore checks before anything changes: room on the data directory, whether the bundle’s format is read directly or through a converter, whether Docker answers (a crew built on another architecture is rebuilt from its image; crews built on an older image than this server holds are named, with an images line counting them), host settings that are not carried over for safety (Docker socket mounts, privileged mode, host path binds — read only with a key), and conflicts with the target. For a bundle with complete container environments it prints one line per environment: restore, rebuild (another architecture, or layers not available here: the data comes back, the environment is rebuilt from the crew image) or skip (no Docker), and the layers count toward the space check.

backups drills

GET /api/v1/admin/instance/backups/drills. Every test restore recorded with crewship backup drill --post, newest first, with its result and how many checks did not pass.

backups environments land

POST /api/v1/admin/instance/backups/environments/land. Loads the complete container environments crewship recover staged under <data-dir>/restore-staging/environments/, once the recovered server runs with Docker. Each image is loaded back — a Crewship crew’s under its crew image tag, so the crew is recreated from the captured environment — and a container Crewship does not manage is recreated with its sanitized settings. Every environment is reported restored, rebuilt or skipped with the reason, and the settings not carried over for safety (Docker socket, privileged mode, host paths, added capabilities, devices) stay off. Processes start fresh. Written to the instance audit log. See Complete container environments.

backups upload

POST /api/v1/admin/instance/backups/bundles/upload. Streams an encrypted local archive, up to 64 GiB, with an instance-admin token and no selected workspace. No private key is sent. The returned path is catalogued with checksum proof; contents checks and a test restore are separate. The command sets no overall timeout, so a slow link can take as long as it needs; Ctrl+C cancels the request. Legacy archives needing conversion are marked explicitly.

backups services land

POST /api/v1/admin/instance/backups/services/land. Verifies the quota-service disk images staged by whole-instance recovery, then imports them through the new host’s configured quota helper. Generations are fresh, source namespaces are provenance only, and maintenance release commits with the instance audit. Failed imports or audits retain maintenance; do not resume these services by bypassing the landing. The CLI waits up to 35 minutes; the server limit is 30.

backups settings get

GET /api/v1/admin/instance/backups/settings. The limits (runs at once, CPU cores for compression, disk and upload MB/s), the heartbeat URL and its last ping, which incidents reach the instance admins and after how many hours a plan counts as stale, the drill reminder, the recovery kit, and where copies are kept. “Also tell” lists the notification channels on the alert route with the outcome of the newest backup alert each was sent; “Could tell” lists the channels that could be added, with their ids.

backups test-alert

POST /api/v1/admin/instance/backups/settings/test-alert. Sends a test alert to one notification channel now, through the same delivery a real backup alert uses, and says whether it arrived. Exits non-zero when it did not. The channel need not be on the alert route yet.

backups settings set

PUT /api/v1/admin/instance/backups/settings. Only the flags you pass change. A run that would leave less than 10 % of the disk free does not start and says why; the limits below decide how hard a running backup may push the server. Written to the instance audit log.

backups recipients list

GET /api/v1/admin/instance/backups/recipients. The backup keys: the age public keys backups are encrypted to, who holds each private half, its fingerprint, and the plans that use it. The private halves never reach the server. backups keys is the same command.

backups recipients add

POST /api/v1/admin/instance/backups/recipients. Adds a public key (age1…). Name its id in a plan’s --recipient to encrypt to it. A private key (AGE-SECRET-KEY-1…) is refused. Keep at least two keys held by different people, so one lost key is not a lost backup. Written to the instance audit log.

backups recipients remove <id>

DELETE /api/v1/admin/instance/backups/recipients/{id}. Refused while a plan still encrypts to the key; change the plan first. Backups already made stay readable with the key’s private half. Written to the instance audit log.

backups destinations list

GET /api/v1/admin/instance/backups/destinations. Off-site destinations, how many bundles have a verified copy in each, the last connection test and the plans that copy there. The secret access key is never shown.

backups destinations add

POST /api/v1/admin/instance/backups/destinations. Adds an S3-compatible store (Cloudflare R2, Backblaze B2, Wasabi, MinIO, AWS). The server tests the connection first — it puts, reads back and deletes one small object — and stores nothing when that fails. The secret access key is read from a file, sealed with the server’s vault key and never shown again. A private or loopback endpoint (a MinIO on the LAN) and plain http need --allow-private-network, and the answer then carries a warning; cloud metadata and link-local addresses stay blocked either way. Name the destination’s id in a plan’s --destination to copy that plan’s backups there: each bundle is uploaded after it is written and counts as an off-site copy only once the upload is verified at the destination. Written to the instance audit log.

backups destinations test <id>

POST /api/v1/admin/instance/backups/destinations/{id}/test. Tests the connection and permissions again and stores the outcome. Exits non-zero when the test fails.

backups destinations remove <id>

DELETE /api/v1/admin/instance/backups/destinations/{id}. Refused while a plan still copies there. Copies already uploaded stay in the bucket; only the server’s record of them goes. Written to the instance audit log.

backups incidents

GET /api/v1/admin/instance/backups/incidents. One incident per plan and kind — failed (a run failed, was skipped or was interrupted twice), incomplete, stale, offsite, drill — counted on each repeat and resolved by the next good run or when the condition clears. The drill reminder is one more drill incident with no plan (plan_id null): no test restore within the drill_reminder period, resolved by the next recorded drill. Open incidents first. Each also sits in every instance admin’s inbox.

backups recovery-sheet

GET /api/v1/admin/instance/backups/recovery-sheet. A Markdown page to keep off this server: where the bundles are (this server and every off-site destination), the backup keys’ names and fingerprints, whether the vault keys ride in the recovery kit, the exact crewship recover and crewship backup drill commands, and who the instance admins are. It holds no secret.

backups copies list

GET /api/v1/admin/instance/backups/copies. Every bundle an off-site destination holds (not its environment layers), with scope, size and date, and where this server already has it. Destination ids come from backups destinations list.

backups copies fetch

POST /api/v1/admin/instance/backups/copies/fetch. Restore from an off-site copy: downloads the bundle and the environment layers it needs into this server’s backups directory, each verified, so it can be restored or drilled like a local bundle. The server runs it as a job and answers at once — a large download is never cut by the CLI’s request timeout. --wait follows the job and prints where the bundle landed and the crewship recover command for it. A bundle already on this server is refused with its path.

backups copies status <fetch-id>

GET /api/v1/admin/instance/backups/copies/fetch/{id}. A fetch started without --wait: running, done (and where the bundle is) or failed. A server restart forgets its fetches.

crewship admin instance holds

After crewship recover the server boots with routines and schedules, inbound webhooks and queued work held. Check the restored server, then resume each.

holds list

GET /api/v1/admin/instance/holds. What is held, since when, and how much.

holds resume <key>

POST /api/v1/admin/instance/holds/resume. Resumes routines, webhooks, queue or all. Written to the instance audit log.

crewship admin gdpr

Answers the two requests a person can make about their own data.
Either a user id or an email is accepted; the email is resolved against the workspace roster, because an operator holding a request from a person has their address, not their cuid. --reason is not paperwork. It is the audit trail, and it is what answers “why was this person’s data removed” a year later — so delete refuses without it, and refuses again without --yes. Both commands write an append-only row and return an action id: that id is the receipt for the request you were answering, so record it against the request. export writes to stdout by default (pipe it, redirect it); --out writes a file with 0600, since the file is every row about a person. delete prints a per-table breakdown under the row total, and the labels carry the verb, because the erasure does two different things:
Anonymised rows kept their content and lost the subject’s name (a page version is still there to roll back to); removed / revoked rows are gone, and any capability they carried — a grant, a public /p/{token} link, an inbound webhook token — stops working immediately. Only the second kind is counted in the row total. inbox_item_reads is the subject’s own read markers on this workspace’s inbox items — activity, not items about them. What the erasure promises, and the accountability records it deliberately leaves behind, are in GDPR. An erasure that only partly happened — one table refused or a disk copy could not be removed — comes back as 207 Multi-Status. The receipt is still printed, but the command exits non-zero with erasure incomplete: <first error> and the audit action id, so a scripted SAR queue cannot record it as done; re-run after fixing the cause (the cascade is idempotent, a second run reports zero for what already went).

crewship admin security-posture

Report how this instance is postured: encryption at rest, the private-egress ceiling, signup policy, rate limiting, and whether email/OAuth are configured.
Read-only by design. These are env-driven deploy decisions and are deliberately not settable from the app or from this command — the gap it closes is that their state was invisible without shell access to the box, which the person triaging an incident often doesn’t have.
No secret value is ever returned — only whether something is configured. That is the whole contract, and it’s what lets this be a plain admin GET with nothing worth stealing in the response. A regression test asserts no secret-shaped env value reaches the wire, including via a warning message.

What it reports

Each value is read through the same accessor the runtime enforces with, not re-parsed locally. A posture that parses a flag its own way eventually disagrees with the code that acts on it, and a security report that confidently states the wrong thing is worse than no report.

Intent vs. effect

The rate limiter is reported twice on purpose. In production the limiter runs regardless of the disable flag (MustNotDisableRateLimitInProd), so:
Reporting that as “disabled” would send you chasing an exposure that doesn’t exist; reporting it as plain “enabled” would hide that your config says otherwise and will bite the first time this config lands somewhere the guard doesn’t apply.

Warnings

Beyond the raw state, the report derives warnings that name the consequence:
  • plaintext_secrets_allowed (high) — anyone with the database file can read every stored secret.
  • encryption_key_missing (medium, high when combined with the above) — the combination means new credentials are being written in the clear.
  • rate_limit_disabled (medium) — credential-stuffing and the /credentials/test oracle are unthrottled.
  • rate_limit_disabled_ignored_in_prod (info) — a disable flag is set but the production guard keeps the limiter running. Not an exposure; it means your config and the running state disagree, which matters the first time that config lands somewhere the guard doesn’t apply.
  • signup_open (medium, high in production) — anyone who can reach the instance can create an account.
  • private_endpoints_ceiling_open (info) — crews that also opt in can reach RFC1918/loopback. Link-local and cloud-metadata stay blocked regardless.
The keys above read the environment. The rest read what the instance has actually become, which is the half no config file can tell you:
  • encryption_key_generated (medium) — Crewship bootstrapped the master key itself instead of an operator supplying one, so the key lives with the data it protects.
  • privileged_credentials_enabled (high) — at least one workspace has allow_privileged_credentials on, which removes the fail-closed boundary between privileged crews and stored secrets.
  • private_endpoints_in_use (medium) — crews have opted into private-endpoint egress and the instance ceiling is open, so the reach is live rather than merely permitted.
  • seed_account_default_password (high) — the crewship seed demo account still authenticates with the password printed in these docs.
  • no_backup_recorded (medium) — no backup.* action appears in the audit trail. Reported only when that query succeeds: a failed probe is unknown state, not a missing backup.
The same data backs the Admin → Runtime → Security posture card in the dashboard.

crewship admin memory-config

Read or adjust workspaces.memory_config — currently the retention window for memory_versions rows, consumed by the per-workspace retention sweep. The command also accepts the alias crewship admin memcfg.
The endpoint (GET/PATCH /api/v1/admin/memory/config) exists so retention can be changed without editing SQLite by hand. Until this command there was no client for it, so the hand-edit stayed the only route — which is what the endpoint was built to avoid.

memory-config get

Prints the resolved value and, importantly, whether it was chosen:
30 days on its own doesn’t tell you whether anyone picked it. That matters before changing it: overriding a built-in default is routine, overriding somebody’s deliberate policy is not. When the stored document carries keys this CLI version doesn’t model, they are printed verbatim underneath rather than hidden — otherwise get would look authoritative when it isn’t.

memory-config set --retention-days N

Writes are manage-tier and emit memory.config_updated to the journal, so a compliance audit can trace when retention policy changed and who changed it.
Rows already older than the new window become eligible for deletion on the retention sweep’s next pass. Shortening the window is not a preview — it schedules data loss.
Sent as a partial PATCH carrying only the key being changed. The server merges it into the stored document and preserves settings this CLI doesn’t know about, so an older binary can’t silently drop a newer knob by writing a whole document back. The same data backs the Admin → Runtime → Memory configuration card in the dashboard.

crewship admin memory-stats

Companion read to memory-config: memory-config adjusts the retention window, memory-stats shows what is actually being retained under it.
GET /api/v1/admin/memory/stats, scoped to the current workspace. Three figures:
  • Totals — version count, byte sum, and distinct blob count (a stale row and a fresh row at the same content share one blob), plus the oldest/newest written_at in the window.
  • By tier — one row per memory tier (agent, crew, workspace, …); tiers with zero rows are omitted.
  • By agent — one row per agent slug that has written memory. Crew- and workspace-tier rows have no agent slug and are collapsed under (shared: crew/workspace tier).
Requires OWNER or ADMIN (canRole "manage").

crewship admin memory sync

Run one of the two daily memory sweeps now instead of waiting for its scheduled tick, and see the per-workspace summary the sweep otherwise only logs. Two subcommands, one per sweep:
Before this command the sweeps could only be observed by being awake when they fire. That is worse for the operator model than for most schedulers: nothing is written when the model proposes nothing, when every candidate is refused by the evidence gate, when the curator slot cannot be built, or when the person is below the interaction threshold — and all four look the same from outside. The summary tells them apart:
A TOTAL row is added when more than one workspace was swept. Scope defaults to the current workspace (-w / config). --all runs every active workspace on the instance — exactly what the scheduler does at its tick. The sweeps are safe to re-run: the merge is idempotent and the write is capped. --dry-run runs everything up to the write — including the extractor, which is the model-backed step worth observing — and reports what would have happened. Nothing lands on disk, in the index tables, or in the audit log. This is how a measurement like “how many facts does the extractor propose, and how many does the gate refuse” becomes repeatable without planting data in a live instance; the per-extraction counts are on the server log’s user model extraction lines. The peer-card sweep has no extractor wired on the server today, so it indexes and purges without writing new bodies — the same thing the 04:00 sweep does — and every threshold-crosser shows under EMPTY. --format json prints the server’s report verbatim (sweep, dry_run, workspaces[], totals, duration_ms). Backed by POST /api/v1/admin/memory/user-model-sync and POST /api/v1/admin/memory/peer-card-sync. Requires OWNER or ADMIN (canRole "manage").

crewship admin ratelimits

Inspect and tune the instance’s rate limiters at runtime — the same surface as the dashboard’s Infrastructure → Rate Limiters tab. Every limiter that was once a hardcoded constant (the per-IP HTTP buckets, the login lockout, the notification anti-storm bucket, crew provisioning, agent webhooks) is listed and overridable without a redeploy. Aliases: crewship admin rate-limits and crewship admin ratelimit.
list shows each limiter’s group, key, current value, shipped default, unit, and whether the value is a default or an override:
set <key> <value> overrides a limiter; reset <key> reverts it to the shipped default. Overrides persist in the instance-global rate_limit_overrides table and take effect immediately — the per-IP HTTP buckets are retuned on the live limiter, the rest read their value on next use. Writes are manage-tier (OWNER/ADMIN) and logged at WARN with the actor. A value outside a limiter’s allowed range is rejected (400); an unknown key is 404.
Raising http.auth_per_min is the fix for an instance whose users get logged out under heavy refresh behind a shared NAT. On current builds the read-only session polls that caused that no longer touch this bucket, so most instances never need to.

crewship admin seed-inbox

Fill a workspace’s inbox with one row of every kind — waitpoints, escalations, a failed run, messages, a memory proposal, schedule alerts — plus approval-queue rows both pending and decided. It exists because the inbox has no create endpoint: every row is written by a producer (a pipeline reaching a waitpoint, an agent escalating, the keeper sweeping), so a fresh workspace shows an empty inbox and there is no way to see how the views, facets and the reading pane behave against real variety. Rows go in through the same writers the real producers use, so what lands is shaped exactly like production data rather than hand-built SQL that agrees with whatever the UI happens to expect.
Fixture, not a runtime test. The run behind a seeded approval gate is a row, not an execution: no executor is parked on the waitpoint, so approving a seeded gate flips the token and cascades the inbox row and then nothing resumes.Use it to remove “is it seeded?” preconditions from a test, and to prove a button reaches its endpoint — never as evidence that a decision took effect. That is the approval-gate-demo routine’s job — crewship routine run approval-gate-demo parks a real run on a real gate, and approving it resumes the run.Stop the server before seeding: seed-inbox writes the database file, and a running server owns the only writer. Seeded approval gates do not survive the next start — the start-up sweep settles gates whose pipeline run is missing or terminal (CancelOrphanedWaitpoints) — so after seeding, the other rows are usable but the gates are already settled.

crewship admin cleanup

Read container cleanup observations from the server selected by the CLI. This HTTP read requires login. Instance admins can omit the workspace and read the whole installation; workspace ADMIN/OWNER users must select a workspace and see only its observations. Unlike local recovery commands, it does not open a database file. It is backed by GET /api/v1/admin/resource-cleanup and never triggers cleanup itself.
The response includes scope=containers, controller state and per-crew items for this installation (instance admins) or the selected workspace (workspace admins), with observation time, completeness, remaining eligible containers (and per-crew networks, when the crew had one), legacy unattributed count and fixed error codes. observed_clear describes the last complete eligible-container and crew-network scan, not volumes, host data or future creates. Stale observations read as unknown. If the crew’s workspace is deleted or the server is unavailable, use crewship doctor cleanup --format json on the host to read historical local snapshots without workspace membership.