# Part 5: CLI Tool (`packr-cli`)

**Previous:** [`docs/04-registry-api.md`](04-registry-api.md)
**See also:** [`docs/08-cli-auth.md`](08-cli-auth.md) (credential storage & login flows)

---

## Quick Reference

```bash
packr-cli                         # Print full usage
packr-cli <command> --help        # Per-command help (intercepts before dispatch)
```

All subcommands accept `--help` / `-h` at any position; the CLI prints the usage
block and exits 0 without dispatching to the handler.

```bash
packr-cli version                 # packr-cli v0.64.0 (ae48647, built 2026-10-26T18:29:35Z)
packr-cli --version               # same; only as the first argument
```

An unstamped build (`go build` without `-ldflags`) reports `dev (unknown, built
unknown)`. `make build-cli` stamps it from `VERSION`, `git rev-parse` and the
clock via `-X main.version=… -X main.commit=… -X main.date=…`; the binary in the
registry image picks up the identity the Dockerfile stamps into
`internal/version`. `packr-cli doctor` prints the CLI's version next to the
server's, so a stale binary is visible.

### One-shot usage

```bash
# Login once (creates ~/.packr/credentials.json)
packr-cli login                              # OAuth device flow (prompts in browser)
packr-cli login --password "$PACKR_CI_PASSWORD" --name blueforge-ci   # CI-friendly, no browser
packr-cli login --scope @learnquest          # Restrict to one npm scope

# Inspect who's logged in
packr-cli whoami

# Create a publish token (works against remote registry via stored JWT)
packr-cli token create --name dev-publish --role admin --scope @learnquest --expires 90

# Publish
PACKR_TOKEN=$(packr-cli token create --name dev --role admin --scope @learnquest --expires 90) \
  pnpm publish --no-git-checks

# Is it published?
packr-cli view @learnquest/utils@1.2.0 >/dev/null && echo published
```

---

## Overview

`packr-cli` is the official command-line client for the Packr registry. It can run in two modes:

| Mode | Trigger | Used when |
|------|---------|-----------|
| **Local DB** | No `PACKR_REGISTRY` env var AND no `--registry` flag | Running server-side (inside the registry container on app-3, `/opt/packr-registry/…`) — reads/writes the on-disk DB directly |
| **Remote API** | `PACKR_REGISTRY` env, `--registry` flag, OR single credential in `~/.packr/credentials.json` | Running on a developer laptop — talks to the registry over HTTPS via stored JWT |

The remote-API mode is what 99% of users touch. It reuses the JWT stored by `packr-cli login` and routes calls through the admin API endpoints below.

> **Default registry:** When `PACKR_REGISTRY` is unset and no `--registry` is passed, the CLI defaults to `http://localhost:4873`. To target the live instance, set `export PACKR_REGISTRY=https://api.packr.blueforge.studio` or pass `--registry https://api.packr.blueforge.studio` explicitly.

---

## Full Command Tree

```
packr-cli
├─ license                 License key management
│  ├─ activate <key>       Activate license key (offline HMAC check)
│  └─ status               Show current license status
│
├─ token                   Personal access tokens
│  ├─ create <name>        Create a token (remote: POST /api/v1/admin/users/me/tokens)
│  │                       --output-file <path> writes it instead of printing it
│  │                       --to-forge-git <owner>/<repo>:<SECRET> puts it in a forge-git secret
│  │                       --as-user <id> mints FOR that user via the operator route (no login)
│  ├─ list                 List your tokens, with expiry and last use  (remote: GET .../tokens)
│  ├─ reissue <id>         Mint a replacement with the old scopes + --add-scope/--add-group
│  │                       [--revoke-old] retires the original — default keeps it alive
│  ├─ verify               Would this credential work? signature / stored / scope paths
│  │                       [--ecosystem E] [--scope S] [--json]; exit 0/1/2
│  └─ revoke <id>          Revoke a token    (remote: DELETE .../tokens/:id)
│                          falls back to the operator-secret route on 401/403
│
├─ token-limit             Per-user active-token ceiling
│  ├─ get <user-id>        Effective ceiling + where it comes from (user|env|plan)
│  ├─ set <user-id> <n>    Set a ceiling (super-admin; hard cap 1000)
│  └─ clear <user-id>      Drop the override, back to the plan's ceiling (super-admin)
│
├─ scope-group             Named scope groups, expanded at mint time
│  ├─ list                 All groups
│  ├─ get <name>           One group's scopes
│  ├─ set <name> <s>...    Define a group (super-admin)
│  └─ rm <name>            Delete a group (super-admin)
│
├─ user                    User accounts (admin operation)
│  └─ create <name> [--password <p>]   Create a user (local DB only)
│
├─ login                   Authenticate and store credentials
│  ├─ (default)            OAuth device flow (GitHub, Google, GitLab, OIDC)
│  └─ --password <p>       Username/password fallback (CI-friendly)
│
├─ logout [--scope @x]     Remove stored credential + clean .npmrc
├─ whoami                  Print authenticated user, role, scopes, expiry
├─ doctor                  Reachability, CLI + server versions, auth + credential expiry,
│                          ecosystem endpoints, a real tarball download, local configs
├─ version | --version     CLI version, build commit and build date
│
├─ view <pkg>[@<version>]  Packument: latest, versions, publish times, dist-tags  (GET /<pkg>)
│  ├─ versions | dist-tags | time    Just that field
│  └─ --json                         The field(s) as JSON
│
├─ init [--scope @x]       Create .packr.json and update .npmrc for this project
│
├─ config                  Local CLI config
│  └─ set credential-store {file|keychain|secret-service}
│
├─ rotate [--name N]       Rotate stored token (creates new one with same role/scopes)
│         [--registry R]   ... against a different registry than the stored one
│         [--scope S]      ... narrowed to one npm scope
│         [--expires D]    ... valid for D days instead of the server's 30-day default
│         [--output-file F | --to-forge-git <owner>/<repo>:<SECRET>]   ... destination
│
├─ owner                   Package ownership (remote API)
│  ├─ list <scope>/<pkg>              GET  .../owners
│  ├─ add <scope>/<pkg> <user>        POST .../owners
│  ├─ remove <scope>/<pkg> <user>     DELETE .../owners/:user_id
│  └─ transfer <scope>/<pkg> <user>   POST .../transfer
│
├─ visibility              Set package visibility
│  └─ <ecosystem>/[scope/]<name> <public|private>   POST .../packages/visibility
│
├─ policy                  Per-package supply-chain gates (signature/sbom/provenance)
│  └─ <ecosystem>/[scope/]<name> --gate G --policy block|off|inherit
│
├─ yank / unyank           Pull a version out of resolution / put it back
│  └─ <ecosystem>/[scope/]<name>@<version> [--reason "why"]
│
├─ deprecate / undeprecate Mark/unmark a version deprecated (with a message)
│  └─ <ecosystem>/[scope/]<name>@<version> [--message "why"]
│
├─ verify <eco>/[scope/]<name>@<version>
│                          Artifact integrity/signature/provenance — DISTINCT from
│                          `token verify` (that one checks a credential)
│  ├─ --artifact <path>    Verify a local file instead of the registry copy
│  └─ --key <path>         Extra keyring for signature checks
│
├─ migrate-storage         Move data between storage backends (dry-run default)
│  └─ --from <dir|postgres-url> --to <dir|postgres-url> [--apply] [--overwrite]
│
├─ config <eco> [--write]  Print/write local toolchain config (npm, go, cargo,
│                          pypi, maven, gradle, nuget) [--with-token] [--force]
│
├─ prune <[@scope/]name> (--before <ver> | --keep-latest) --yes
│                          Permanently delete versions and their tarballs (npm packages);
│                          refuses without --yes. POST .../prune-before | prune-keep-latest
│
├─ cover set <[@scope/]name> <url-or-repo-path> | clear <[@scope/]name>
├─ features set <[@scope/]name> <bullet>... | clear <[@scope/]name>
│                          Catalog cover and feature bullets. PUT .../cover | features
│
├─ sbom attach|get <eco>/[scope/]<name>@<ver> [<file>]
├─ attestation attach|get <eco>/[scope/]<name>@<ver> [<file>]
│                          Attach a CycloneDX/SPDX SBOM or a DSSE/Sigstore attestation to a
│                          version, or print the stored one (get writes to stdout)
│
├─ publish-*               Publish through the registry from a file
│  ├─ publish-jvm <scope>/<name> <ver> --jar <path> --module <path>
│  ├─ publish-maven <groupId>/<name> <ver> --jar <path> --pom <path>
│  ├─ publish-cargo <crate> <ver> --crate <path.crate>
│  ├─ publish-nuget <nupkg-path> [--symbol <snupkg-path>]
│  ├─ publish-go <module> <ver> --mod <go.mod> --zip <path.zip>
│  ├─ publish-pypi <name> <ver> --file <path> [--filetype sdist|bdist_wheel]
│  ├─ publish-gems <path-to-.gem>
│  ├─ publish-dart <path-to-.tar.gz>        (name/version read from the pubspec inside)
│  ├─ publish-generic <scope>/<name>@<ver> <file>... [--as <remote-name>]
│  │                                       (re-publishing an identical file is a no-op)
│  ├─ promote-generic <scope>/<name> <channel> <ver>   Point a channel at a version
│  └─ publish-npm [dir] [--pm pnpm|npm] [--tag <t>] [--access public|restricted]
│                 [--dry-run] [--no-git-checks]       Wraps pnpm/npm publish
│       (plain npm publish works too — point npm at the registry via .npmrc)
│
├─ admin                   Super-admin operations
│  └─ reset-owners <scope>/<pkg> <owners...>    POST .../owners/reset
│                                                  (requires PACKR_SUPER_ADMIN_TOKEN
│                                                   or --super-admin-token)
│
└─ agent-*                 AI-agent helpers
   ├─ agent-info <@scope/pkg>         GET /agent/<pkg>
   ├─ agent-search <capability>       GET /agent/search?q=...
   └─ agent-deps <@scope/pkg>         GET /agent/<pkg>/deps
```

---

## Authentication

### `packr-cli login` — OAuth device flow (default)

```bash
packr-cli login                                      # uses default provider
packr-cli login --provider github
packr-cli login --provider google
packr-cli login --provider gitlab
packr-cli login --provider oidc                     # custom OIDC (Keycloak, Okta, Azure AD)
packr-cli login --scope @learnquest                 # restrict to one scope
packr-cli login --registry https://api.packr.blueforge.studio
```

What happens:

1. POST `/-/v1/device/authorize` with chosen provider → registry returns `device_code`, `user_code`, `verification_uri`.
2. CLI prints the URL + code, opens your default browser.
3. CLI polls `/-/v1/device/token` every 5 s until approved.
4. JWT is stored in `~/.packr/credentials.json` (mode 0600) — or in the OS keychain if `credential-store` is set to `keychain`/`secret-service`.

> **Live registry note:** the `verification_uri` points at `packr.blueforge.studio/cli/auth`, served by the Next.js dashboard (forge-control deploy, live since 2026-08-20) — the device flow works end-to-end. On a headless machine (CI, SSH) there is no browser to complete it; use the password login below.

### `packr-cli login --password <pwd> --name <user>` — CI-friendly fallback

For registries in `standalone` or `hybrid` auth mode, you can log in with `username + password`. Works against `POST /-/v1/login`.

```bash
# Non-interactive (CI) — the recommended form:
packr-cli login --password "$PACKR_CI_PASSWORD" --name blueforge-ci

# Equivalent, username as a trailing positional:
packr-cli login --password "$PACKR_CI_PASSWORD" -- blueforge-ci

# Restrict to a scope and update .npmrc:
packr-cli login --password "$PACKR_CI_PASSWORD" --name blueforge-ci --scope @learnquest
```

> **There is no interactive prompt.** `--password` takes its value from the
> next argument, the same as every other flag in this CLI. `packr-cli login
> --password` with nothing after it leaves the password **empty**, which is
> not an error — `parseLoginFlags` returns no complaint — and the command
> falls through to the **OAuth device flow**. On a headless box that reads as
> a hang while it waits for a browser you do not have. If you meant the
> password flow, you must pass the password.

> **Username placement matters, because the parser is positional.** It takes
> the first argument that does not begin with `--` as the username, and
> silently **ignores** every later positional. So
> `login --password -- blueforge-ci "$PASS"` does not do what it looks like:
> `--` is consumed as the *password*, `blueforge-ci` becomes the username, and
> the real password is dropped on the floor — you get a 401 from
> `POST /-/v1/login` with no hint as to why. Use `--name <user>` and the
> ambiguity disappears.

> A `--password` with no username at all **is** rejected, with a message
> naming both accepted forms (`--name/-u`, or a trailing positional).

### `packr-cli logout [--scope @x]`

Removes stored credential and scrubs `~/.npmrc` lines for that scope/registry.

### `packr-cli whoami`

```text
$ packr-cli whoami
Logged in as kristianmandrup (admin) via password — expires 2026-10-08
```

Returns non-zero exit code if not logged in (script-friendly).

`whoami` reports **identity, not authority** — it prints the user, the role
label, the login method and the expiry, and nothing about the scopes the
credential actually carries. If you exported `PACKR_TOKEN`, it says so and
stops there (`no stored session`), because an env token carries no stored
metadata. To check what a credential can actually reach, ask the registry:
`GET /-/v1/credential/verify?scope=<scope>` answers the scope question
separately from signature and ownership.

---

## Project Setup: `packr-cli init`

Writes `.packr.json` (registry URL + scope) and updates the project-level `.npmrc` to point at the registry with `//host/:_authToken=${PACKR_TOKEN}` (NOT the actual JWT — resolves from env at install/publish time).

```bash
packr-cli init --scope @learnquest
```

**Generated `.packr.json`:**

```json
{
  "registry": "https://api.packr.blueforge.studio",
  "scope":    "@learnquest"
}
```

**Generated `.npmrc`:**

```
@learnquest:registry=https://api.packr.blueforge.studio
//api.packr.blueforge.studio/:_authToken=${PACKR_TOKEN}
```

Notes:

- Only lines containing `<scope>:registry=` or `//<host>/:_authToken=` get replaced; other `.npmrc` entries are preserved.
- The CLI writes `${PACKR_TOKEN}` into the project `.npmrc` (NOT the literal token) so the file can be committed safely. The token value comes from `PACKR_TOKEN` env var at install/publish time.
- `~/.npmrc` (user-level, written on `login --scope`) DOES contain the literal token — keep that file mode 0600 and out of git.

---

## Credential Store

The CLI saves tokens in one of three places:

```bash
packr-cli config set credential-store file           # ~/.packr/credentials.json (default)
packr-cli config set credential-store keychain       # macOS Keychain / Windows Credential Manager
packr-cli config set credential-store secret-service # Linux libsecret (GNOME Keyring, KWallet)
```

The active backend is stored in `~/.packr/config.json`. Setting re-applies on next `login` / `rotate`. See [`docs/08-cli-auth.md`](08-cli-auth.md) for full credential-file schema and keychain instructions.

---

## Token Management

### `packr-cli token create <name>`

Creates a personal access token bound to the currently authenticated user.

**Flags:**

| Flag | Description |
|------|-------------|
| `--role` | Preset role: `ci-readonly`, `ci-publish`, `maintainer`, `admin`. **Defaults to `ci-publish`** if omitted — the server applies this (`internal/npm/admin_tokens.go`), the CLI does not send a role at all unless you pass one. |
| `--scope` | Restrict to one npm scope (repeatable for multiple). Empty = all scopes. |
| `--ecosystem` | Restrict to one ecosystem (`npm`, `cargo`, `pypi`, …). Used with `--namespace`. |
| `--namespace` | Restrict to one namespace within that ecosystem. **See the warning below.** |
| `--expires` | Override default 30-day expiry in days. |
| `--registry` | Target registry URL (alternative: `PACKR_REGISTRY` env). |
| `--output-file` | Write the token to this path (mode `0600`) instead of stdout. |
| `--to-forge-git` | `<owner>/<repo>:<SECRET>` — write the token into that forge-git Actions secret instead of stdout. Cannot be combined with `--output-file`. See [below](#straight-into-a-forge-git-secret). |
| `--as-user` | `<web:id>` or `<id>` — mint **for that user** through the operator route (`POST /api/v1/admin/tokens/mint`, `X-Super-Admin-Token`). No stored login required. See [Operator mint](#operator-mint-as-user--mint-without-a-login). |
| `--super-admin-token` | Operator secret for `--as-user` (also read from `PACKR_SUPER_ADMIN_TOKEN`). Only valid together with `--as-user`. |

> ### ⚠️ An ecosystem with no namespace means *every* namespace
>
> `--scope` and `--ecosystem`/`--namespace` are **not** equivalent, and the
> second pair is easy to under-scope by accident. The check is
> `internal/auth/permissions.go`:
>
> ```go
> if ps.Ecosystem != "" && ps.Ecosystem != ecosystem { return false }
> if ps.Namespace == "" { return true }   // <-- ALL namespaces in that ecosystem
> return ps.Namespace == namespace
> ```
>
> - `--ecosystem npm` with **no** `--namespace` grants **every npm namespace**.
>   It is the ecosystem-wide form, and it is strictly *broader* than
>   `--scope @blueforge-studio`.
> - A bare `--scope @blueforge-studio` (legacy form, `Ecosystem == ""`) **is**
>   enforced per-namespace, because the empty-ecosystem branch is never reached.
>
> So `--scope` is the safer of the two, and `--ecosystem X` alone is a much
> larger grant than it looks. Check a minted token's `scopes` claim before
> installing it anywhere: `packr-cli token create` echoes the scopes it stored,
> and `GET /-/v1/credential/verify?scope=` reports the scope check separately
> from signature/ownership.

When the registry URL resolves to the live server, the call goes through `POST /api/v1/admin/users/me/tokens`. The CLI prints the **raw token** to stdout (so it can be piped into `gh secret set` or `PACKR_TOKEN=$(...)`) and a one-line summary to stderr.

#### Operator mint: `--as-user` — mint without a login

The self-service route authenticates as **you**, so it needs a working
session. That is exactly what you do not have after a `JWT_SECRET` rotation:
publishing still works (hash lookup) but every admin route 401s, and the
credential most in need of replacement is the one least able to replace it.

`--as-user` switches to the operator route, authenticated by the shared
secret alone — no stored login, no `PACKR_TOKEN`, no `packr-cli login`:

```bash
export PACKR_SUPER_ADMIN_TOKEN=<operator secret>   # ~/.forge-secrets/packr-super-admin.token
f=$(mktemp); trap 'rm -f "$f"' EXIT
packr-cli token create "rotate-$(date +%F)" \
  --role admin --scope @blueforge-studio \
  --as-user web:14 --output-file "$f"
```

The request goes to `POST /api/v1/admin/tokens/mint` with
`X-Super-Admin-Token` and **no** `Authorization` header — the audit row names
the secret (`actor=super-admin`, `details=… target=web:14 via=operator-mint`),
not a stale session. `--super-admin-token <tok>` overrides the env var, but
is refused without `--as-user` so a secret can never be passed and silently
ignored. The ordinary per-plan token ceiling still applies: a shared secret
is not a way around `token-limit`.

The same route accepts `X-Internal-Secret` (the dashboard's pairing secret)
— the CLI does not send it; use `curl` if you are wiring the site.

#### Keeping the token out of logs and agent context

`--output-file` writes the token to disk and prints **nothing** to stdout, so the
credential never lands in a terminal transcript, a CI log, or an AI agent's
context window. An agent can mint a credential and hand the path to whatever
consumes it without ever holding the value itself:

```bash
$ packr-cli token create ci-publish --role ci-publish --scope @blueforge-studio \
    --output-file ~/.packr/ci-token
Token written to /Users/me/.packr/ci-token (mode 0600)

$ gh secret set PACKR_TOKEN < ~/.packr/ci-token && rm ~/.packr/ci-token
```

If the file cannot be written the command **fails** rather than falling back to
printing — a fallback would defeat the flag exactly when secrecy was asked for.
Because the token already exists server-side at that point, the error names its
id so it can be revoked rather than left orphaned.

#### Straight into a forge-git secret

`--to-forge-git` removes the file too. The token goes from the registry's
response into a forge-git (Gitea) Actions secret inside one process: it is
never printed and never written to disk.

```bash
export FORGE_GIT_URL=https://forge-git.blueforge.studio
export FORGE_GIT_TOKEN=...   # a forge-git access token with write access to the repo

$ packr-cli token create ci-publish --role ci-publish --scope @blueforge-studio --expires 90 \
    --to-forge-git blueforge/blueforge-org:PACKR_TOKEN_PUBLISH
secret PACKR_TOKEN_PUBLISH set on blueforge/blueforge-org (token id 145, expires 2026-12-12)
```

It calls `PUT {FORGE_GIT_URL}/api/v1/repos/{owner}/{repo}/actions/secrets/{SECRET}`
with `Authorization: token $FORGE_GIT_TOKEN`. The credentials come from the
environment only — never a flag, which would land in shell history and `ps`.

Everything that can be checked is checked **before** a token is minted, and
fails with exit `2` (or `1` for missing `FORGE_GIT_*` variables): the
`owner/repo:SECRET` shape, owner and repo characters, and the secret name
(`A–Z`, `0–9`, `_`, not starting with a digit or with the reserved `GITEA_` /
`GITHUB_` prefixes).

If the secret PUT fails after the token was minted, the CLI revokes that token
at once and says so, and exits `1` — an unused live credential is not left
behind. If the revoke fails as well, it prints the id and the exact
`packr-cli token revoke <id>` to run.

> `packr-cli rotate` never prints a token at all: it writes the new value
> straight into `~/.packr/credentials.json` and `~/.npmrc`. Prefer it over
> `token create` when replacing a credential that is already in use.

### Rotating a credential without ever seeing it

`rotate` keeps the token off stdout by storing it, which is what makes it safe
to run in CI or under an AI agent. But a rotation has a second half -- pushing
the new value into CI secrets -- and that used to mean reading the token back
out, putting it straight into the log or context the storage was protecting it
from. `rotate --output-file` closes that gap.

Authentication is the other half, and **normally there is no password to
supply at all**. `packr-cli login` runs the OAuth device flow: it prints a URL
and a short user code, a human approves in a browser, and the CLI writes
`credentials.json` itself. Nothing secret is ever displayed, so this is safe to
run under an agent -- the code on screen is not a credential.

```bash
packr-cli login --provider github --scope @acme   # approve in the browser
```

Reach for `PACKR_CREDENTIALS_FILE` only where no human can approve a browser
prompt -- an unattended CI job. It points at a `0600` file holding the username
and password on two lines, so the caller passes only the *path*; an env var
would hand the password to every child process, and on the command line it is
visible in `ps`.

```bash
# only for headless use; written once, by a human, in their own terminal
umask 077 && printf '%s\n%s\n' "$USERNAME" "$PASSWORD" > ~/.packr/creds
export PACKR_CREDENTIALS_FILE=~/.packr/creds
```

Either way, the rotation itself is the same and displays no secret:

```bash
f=$(mktemp); trap 'rm -f "$f"' EXIT

packr-cli rotate --scope @acme --name rotated-2026-09 --output-file "$f"
gh secret set PACKR_TOKEN --repo acme/some-repo < "$f"

packr-cli token list          # find the OLD id
packr-cli token revoke <old>  # only after the new one is in place
```

The credentials file is rejected if it is readable by group or others -- a
world-readable password file is barely better than an env var. Revoke last:
`rotate` deliberately leaves the old token active so there is no window where
publishing is broken.

```bash
$ packr-cli token create --name dev-publish --role admin --scope @learnquest --expires 90
eyJhbGciOiJIUzI1NiIs...
Created token id=42 name="dev-publish" role=admin scopes=[@learnquest]
```

Response shape (from `internal/npm/admin_tokens.go`):

```json
{
  "id":          42,
  "token":       "eyJhbGciOiJIUzI1NiIs...",
  "name":        "dev-publish",
  "role":        "admin",
  "permissions": ["read","publish","unpublish","admin"],
  "scopes":      ["@learnquest"]
}
```

> **Publishing a brand-new package does not require `admin`.** This used to be
> documented here as a "first-publish E403" that forced you to mint an admin
> token. That gate does not exist. `PUT /@scope/pkg` makes exactly two
> authorization checks (`internal/npm/registry_publish.go`): the token needs
> the `publish` permission, and it needs to cover the package's scope. The
> ownership check is *skipped for CI tokens* precisely because the scope
> restriction is the authorization — the code comment says so in as many
> words. `ci-publish --scope @yourorg` publishes new packages fine.
>
> If you hit a 403 on publish, it is one of exactly two messages:
> `insufficient permission: requires publish`, or
> `token not authorized for scope: <scope>`. The second one names the scope
> it wanted — match the token to it. Neither is fixed by escalating to admin.

### `packr-cli token list`

```text
ID    Name                      Role           Scopes               Created      Expires      Last used
42    dev-publish               admin          [@learnquest]        2026-07-10   unknown      2026-09-12
38    rotated-password          admin          [@learnquest]        2026-06-30   unknown      never
```

`Expires` shows `EXPIRED` for a token past its date. It reads `expires_at` from
the list response, which the server does not send today — the mint path never
persists an expiry, it lives only inside the JWT — so the column says `unknown`
rather than guessing from the creation date (a `--expires 90` token would look
dead at day 30). To see a token's real expiry, run `packr-cli doctor` with it as
`PACKR_TOKEN`; it reads the JWT's `exp` claim.

### `packr-cli token revoke <id>`

Deletes the token. Idempotent — re-revoking a deleted token returns 404.

**Flags:**

| Flag | Description |
|------|-------------|
| `--super-admin-token` | Operator secret, for revoking a token you cannot authenticate as (also read from `PACKR_SUPER_ADMIN_TOKEN`). |
| `--reason` | Recorded in the registry's audit log. |

A 401 or 403 from the ordinary route usually does **not** mean you lack authority —
it means your own credential can no longer authenticate. A token issued before the
last `JWT_SECRET` rotation still publishes (that path is a hash lookup) but fails
every admin route (those verify the JWT signature), so it cannot revoke itself. The
CLI recognises this and says so:

```
Your own credential could not authenticate against the admin API.
If it predates the last signing-key rotation it will still publish but
cannot revoke. Retry with the operator secret:
  packr-cli token revoke 19 --super-admin-token <token>
```

With the secret supplied, it posts `{"id": N}` to `/api/v1/admin/tokens/revoke`,
which deletes the row. That is the only form that retires a token predating `jti`
claims: there is no `jti` to denylist, and deleting the row is what stops the hash
lookup it authenticates on. See [Part 7 — Token Lifecycle](07-tokens.md#token-lifecycle).

### `packr-cli token verify`

Answers the question *"would the operation I am about to run be allowed"* —
which is a different question from `whoami` ("what does the registry know
about this credential") and from `verify` ("is this artifact intact"). Those
came apart once already: a token whose JWT signature stopped validating still
returned full claims from `whoami`, so the credential looked healthy and was
dead (issue #60).

```bash
packr-cli token verify                                  # can this credential work at all?
packr-cli token verify --ecosystem npm --scope myorg    # …for a specific publish target
packr-cli token verify --json                           # machine-readable, for CI gates
```

It reports the paths **separately**, because they genuinely disagree in
both directions:

| Check | Path it exercises | Feeds |
|---|---|---|
| `signature` | JWT validation — is this a valid session? | `can_session` |
| `stored` | token-hash lookup in the `tokens` table — what publish/install checks | `can_publish` |
| `scope` | publish authorization for `--ecosystem`/`--scope`, when given | scope fit |
| `admin` | explicit admin permission, read the way an admin route reads it | `can_admin` |

A stored token with a stale signature **publishes fine and cannot administer**;
a signature-valid JWT missing its row does the reverse. The endpoint
(`GET /-/v1/credential/verify`) calls the *same* `core.Authenticate` /
`core.AuthorizePublish` functions a real publish does, so it cannot drift
from the behavior it predicts.

**`can_admin` is not the signature check.** They were the same field until they
were split. It used to report a `ci-publish` token — and a plain
`packr-cli login` — as able to use the admin API, because "the JWT verifies" was
standing in for "this is permitted". A credential can be a publisher, an
administrator, a session, or several:

| | `can_publish` | `can_admin` | `can_session` |
|---|---|---|---|
| `packr-cli login` session | ✗ | ✗ | ✓ |
| `ci-publish` token | ✓ | ✗ | ✓ |
| admin-role token | ✓ | ✓ | ✓ |
| opaque CI token | ✓ | ✗ | ✗ |

**Exit codes:** `0` if any axis is usable, `1` if none is (reason printed), `2`
the check could not run (network/no credential) — safe as a CI gate. With
`--ecosystem`/`--scope`, only publish authorization governs:

```bash
packr-cli token verify --ecosystem npm --scope @myorg || exit 1
```

### `packr-cli token reissue <id>`

Mints a **replacement** carrying the original's scopes plus additions — the
way to widen a token without distributing a fresh all-scopes credential:

```bash
packr-cli token reissue 42 --add-scope @other-org            # add one scope
packr-cli token reissue 42 --add-group platform              # add a named scope group
packr-cli token reissue 42 --add-scope @b --revoke-old       # replace in place
packr-cli token reissue 42 --name ci-2026q4 --output-file f  # rename + keep it off stdout
```

The old token **stays valid** unless you pass `--revoke-old` — install the new
one first, retire the old one last, exactly like `rotate`. Scope groups are
expanded at reissue time (the new token records the concrete scopes, not the
group name), so later edits to a group never change what this token can do.
Without `--revoke-old`, reissue still counts against the same per-plan token
ceiling as every other mint — the ceiling error message names `revoke_old` as
the replace-in-place way out.

Remote route: `POST /api/v1/admin/users/me/tokens/{id}/reissue` (session or
JWT — see [API.md](API.md)).

### `packr-cli scope-group <list|get|set|rm>`

Named bundles of scopes used at **mint** time. A group is named on `reissue`
with `--add-group`; on `token create` you pass the expanded `--scope` values
directly (there is no `--groups` flag on `token create`):

```bash
packr-cli scope-group list                 # every group
packr-cli scope-group get platform         # one group's scopes
packr-cli scope-group set platform @acme/@platform @acme/ui   # define/replace (super-admin)
packr-cli scope-group rm platform          # delete (super-admin)
```

A group name is what a human *grants*; the token records the expanded scopes.
Editing a group after minting changes only **future** mints — existing tokens
are frozen at their expansion. Reads need any authenticated session; `set`/`rm`
require the super-admin secret (same `X-Super-Admin-Token` / claims authority
as other super-admin writes — see [AUTH.md](AUTH.md#super-admin-token-x-super-admin-token)).

Routes: `GET/PUT/DELETE /api/v1/admin/scope-groups[/{name}]`.

### `packr-cli token-limit <get|set|clear>`

The per-plan active-token ceiling, with an optional per-user override:

```bash
packr-cli token-limit get web:14          # effective ceiling + where it comes from
packr-cli token-limit set web:14 50       # per-user override (super-admin; hard cap 1000)
packr-cli token-limit clear web:14        # back to the plan's ceiling (super-admin)
```

Resolution order (what `get` reports as the source): per-user override →
env override → plan env → plan default (free = 20). `set`/`clear` are
super-admin operations. `<user-id>` is the registry id in `web:<n>` form —
`GET /-/whoami` prints yours in the `user_id` claim (`whoami` the command
does **not** show it).

Routes: `GET/PUT/DELETE /api/v1/admin/users/{user_id}/token-limit` — GET needs
a session (another user's requires admin), PUT/DELETE need super-admin.

### `packr-cli rotate`

Rotates the **stored** token: POST to `/api/v1/admin/users/me/tokens` with the same role/scopes, swaps it in `~/.packr/credentials.json` and writes the new value into `~/.npmrc` if a scope is set. The old token remains active until you revoke it explicitly (run `packr-cli token list` → `token revoke <old-id>`).

If the POST fails with 401/403 (e.g., the stored token is no longer authorized for the admin endpoint), `rotate` falls back to password-based re-auth — but only if `PACKR_USERNAME` and `PACKR_PASSWORD` are set in the env. Otherwise it errors out with instructions to run `packr-cli login`.

```bash
packr-cli rotate --name "rotated-2026-07"          # optional token name
packr-cli rotate --scope @learnquest               # restrict + update .npmrc
packr-cli rotate --expires 90                      # valid for 90 days, not the default 30
packr-cli rotate --expires 90 --to-forge-git blueforge/blueforge-org:PACKR_TOKEN_PUBLISH
```

`--expires` takes a positive whole number of days and is validated like
`token create`'s. The expiry recorded in `credentials.json` is read from the new
token's `exp` claim, so the "expires in N days" warning fires at the right time.
(The password re-login fallback does not honour `--expires` and says so.)

`--to-forge-git` works as it does for `token create`, with one ordering rule:
the secret is set **before** the new token replaces the stored login. If the
PUT fails, the new token is revoked and nothing on the machine changes — the old
login stays stored and working. On success the new token is stored as every
rotation stores it (credential store and `~/.npmrc`) and put in the secret; it
is never printed and never written to any other file.

Unknown flags and flags missing their value are refused with exit `2`; they
used to be skipped silently.

---

## Is it published? `packr-cli view`

### `packr-cli view <pkg>[@<version>] [versions|dist-tags|time] [--json] [--registry <r>]`

Reads the npm packument — `GET {registry}/{pkg}`, a scoped name escaped as one
segment (`@scope%2fname`) the way npm sends it — from the registry you are
logged in to (`--registry`, `PACKR_REGISTRY`, or the single stored credential),
with your credential when there is one, so private packages answer too.

```bash
$ packr-cli view @blueforge-studio/service-payments
@blueforge-studio/service-payments
  latest    0.4.0
  modified  2026-07-23T21:36:07Z

versions (4):
  0.1.0
  0.2.0
  0.3.0
  0.4.0  [latest]
  (publish times: not recorded per version by this registry)

dist-tags:
  latest: 0.4.0

$ packr-cli view @blueforge-studio/service-payments@0.5.0
@blueforge-studio/service-payments@0.5.0 is not published on https://api.packr.blueforge.studio — latest is 0.4.0
published versions (4): 0.1.0, 0.2.0, 0.3.0, 0.4.0
$ echo $?
1
```

- **Default:** name, `latest`, `created` / `modified`, every version in semver
  order with its publish time and tags, and the dist-tags. Per-version times come
  from the packument's `time` map; packr records only `modified` there today, so
  the listing says so instead of printing a column of blanks.
- **`<pkg>@<version>`:** that version's publish time, tags, tarball and
  integrity, and its deprecation message if any. A dist-tag works in place of a
  version (`@latest`). Exits **1** when the version is not published, naming
  `latest` and the published versions — so a release script can gate on
  `packr-cli view pkg@1.2.3 >/dev/null`.
- **`versions` / `dist-tags` / `time`:** just that field, one entry per line.
- **`--json`:** `versions` is an array in semver order; `dist-tags` and `time` are
  the raw objects; with no field, an object of `name`, `dist-tags`, `versions`
  and `time`; with `@<version>`, that version's raw manifest.

Exit codes: `0` found, `1` package or version absent (404), private to you
(401/403), or any other failed request, `2` usage.

---

## Package Ownership (`packr-cli owner`)

All `owner` commands hit the admin API and require an authenticated session.

```bash
# List owners of @blueforge-studio/service-factory
packr-cli owner list @blueforge-studio/service-factory

# Add a co-owner (by username or numeric user_id)
packr-cli owner add @blueforge-studio/service-factory alice

# Remove a co-owner (resolves username → user_id via list)
packr-cli owner remove @blueforge-studio/service-factory alice

# Transfer ownership (you are removed, target becomes sole owner)
packr-cli owner transfer @blueforge-studio/service-factory bob
```

Notes:

- Removing the **last** owner returns `400 cannot_remove_last_owner` — add the new owner first.
- All mutations are audit-logged (`GET /api/v1/admin/audit`).

---

### `packr-cli visibility <ecosystem>/[scope/]<name> <public|private>`

Set a package's visibility. Requires a stored login JWT (run `packr-cli login`).

```bash
# npm scoped package
packr-cli visibility npm/@acme/private-pkg public

# NuGet (no scope — global id)
packr-cli visibility nuget/GameVault.SDK private
```

---

## Supply chain: `packr-cli verify`

### `packr-cli verify <ecosystem>/[scope/]<name>@<version> [--artifact <path>] [--key <keyring>]`

Checks what the registry recorded about a version against the bytes you hold.

```bash
packr-cli verify cargo/widget@1.0.0 --artifact ./widget-1.0.0.crate
```

```
cargo/widget@1.0.0
  integrity   ok       sha256 matches what the registry stored at publish
  signature   ok       gpg accepted the pgp signature
  provenance  ok       published by alice
```

Three checks, reported separately because they answer different questions:

| | what it means |
|---|---|
| `integrity` | the bytes hash to the digest the registry computed at publish |
| `signature` | your gpg or cosign accepted the stored signature over those bytes |
| `provenance` | who the registry observed publishing it — plus, labelled as unverified, whatever the client claimed about its build |

**The trust root is yours.** The registry stores signatures and never asserts
they are valid, because it has no way to know which keys may sign for which
package. This command runs the verifier on your machine against the keys you
already trust; `--key` points gpg at a specific keyring rather than your
default one.

Exit codes are meant for CI:

| code | meaning |
|---|---|
| `0` | something was checked and nothing contradicted the record |
| `1` | a check failed — the bytes or the signature do not match |
| `2` | nothing could be checked at all |

`2` exists so a pipeline step cannot pass by verifying a version that has
nothing to verify. For the same reason, a verifier that is not installed
reports as *skipped* rather than as a bad signature, and an unsigned version
reports as *absent* rather than failed — most versions are unsigned, and an
error there would train you to ignore the command.

Without `--artifact` it reports what material exists and stops, rather than
implying a check it did not run.

---

## Super-Admin Operations (`packr-cli admin`)

### `packr-cli admin reset-owners <scope>/<pkg> <owner1[,owner2,...]>`

Force-replaces the entire owner list. Used for emergency recovery when the existing owners are unreachable.

```bash
export PACKR_SUPER_ADMIN_TOKEN=<your-token>
packr-cli admin reset-owners @blueforge-studio/abandoned-pkg kmandrup,bob

# Or pass via flag:
packr-cli admin reset-owners @blueforge-studio/abandoned-pkg kmandrup \
  --super-admin-token "$PACKR_SUPER_ADMIN_TOKEN"
```

Underlying endpoint: `POST /api/v1/admin/packages/:scope/:name/owners/reset`
Auth: `X-Super-Admin-Token` header (set on the server as `SUPER_ADMIN_TOKEN`).

The CLI also attaches the user JWT (if any) so audit logs capture who triggered the reset.

---

## User Management

### `packr-cli user create <name> [--password <pwd>]`

Creates a user. **Local-DB only** — it opens its own SQLite handle via
`storage.NewStorage(cfg.DataDir)`, so it writes to the metadata DB *next to
you*, not to a running registry over the network. It cannot create a user on
a remote host. The dashboard's "create user" flow uses
`POST /api/v1/admin/users` instead.

```bash
packr-cli user create blueforge-ci --password "long-random-string"
```

Password is bcrypt-hashed at default cost and stored in the metadata DB.

> **⚠️ `--password` is optional, and the fallback is the literal string
> `changeme`.** Omit the flag and the account is created with a
> world-recognisable password, and the command **does not print it** — so
> there is no output telling you what you just set. The user lands in the
> `default` org as its owner. Always pass `--password`, and on any instance
> that has been reachable by anything other than you, rotate it before doing
> anything else.

---

## License

`packr-cli license activate <key>` — verifies an offline HMAC signature.
`packr-cli license status` — shows current tier, package limit, expiry.

Not required for normal use; only relevant to the hosted service.

---

## Agent Endpoints

Three convenience commands that hit the AI-agent endpoints:

```bash
packr-cli agent-info  @learnquest/utils             # Package metadata + quality score
packr-cli agent-search "form-validation"            # Capability search
packr-cli agent-deps  @learnquest/utils             # Dependency graph
```

Equivalent direct URLs:

```
GET /agent/<pkg>
GET /agent/search?q=<capability>
GET /agent/<pkg>/deps
```

Useful for code-search agents that need an at-a-glance summary of a package.

---

## Examples

### Publish a brand-new package (laptop, brand-new namespace)

```bash
# 1. Login once — use password if the dashboard is unreachable
packr-cli login --password "$PACKR_CI_PASSWORD" --scope @learnquest -- blueforge-ci

# 2. Mint a publish token scoped to the org (new packages included)
TOKEN=$(packr-cli token create --name dev-publish \
  --role ci-publish --scope @learnquest --expires 90)

# 3. Build + publish
cd libs/screens/household
pnpm build
PACKR_TOKEN=$TOKEN pnpm publish --no-git-checks --tag latest

# 4. Verify
curl -s https://api.packr.blueforge.studio/@learnquest/screen-household | jq '.["dist-tags"]'
```

### Rotate an expired CI token

```bash
# 1. Have credentials in ~/.packr/credentials.json (run 'packr-cli login' if not)
export PACKR_REGISTRY=https://api.packr.blueforge.studio

# 2. Rotate
packr-cli rotate --name "ci-publish-2026-q3" --scope @learnquest

# 3. Upload to GitHub
gh secret set PACKR_TOKEN --org learn-quest --body "$PACKR_TOKEN"

# 4. Revoke old
packr-cli token list
packr-cli token revoke <old-id>
```

### Recover an abandoned package's ownership

```bash
export PACKR_SUPER_ADMIN_TOKEN=$(cat ~/.packr-super-admin)
packr-cli admin reset-owners @blueforge-studio/abandoned-pkg new-maintainer

# Verify
packr-cli owner list @blueforge-studio/abandoned-pkg
```

---

## Environment Variables

| Var | Default | Purpose |
|-----|---------|---------|
| `PACKR_REGISTRY` | `http://localhost:4873` | Target registry URL (overridden by `--registry`) |
| `PACKR_CREDENTIAL_STORE` | `file` | One of `file`, `keychain`, `secret-service` |
| `PACKR_SUPER_ADMIN_TOKEN` | — | Used by `admin reset-owners` when `--super-admin-token` not passed |
| `PACKR_USERNAME` / `PACKR_PASSWORD` | — | Used by `rotate` fallback re-auth |
| `PACKR_CREDENTIALS_FILE` | — | Path to a mode-`0600` two-line user/password file used by `rotate`'s re-login fallback (preferred over `PACKR_PASSWORD`; see [above](#rotate-an-expired-ci-token)) |
| `PACKR_TOKEN` | — | Token to use instead of the stored login; `doctor` warns when its JWT `exp` is within 7 days |
| `FORGE_GIT_URL` | — | forge-git base URL for `--to-forge-git` (e.g. `https://forge-git.blueforge.studio`) |
| `FORGE_GIT_TOKEN` | — | forge-git access token with write access to the target repo, for `--to-forge-git` |

---

## Troubleshooting

| Symptom | Cause | Fix |
|---------|-------|-----|
| `Error: not logged in to <url>` | No JWT in `~/.packr/credentials.json` | Run `packr-cli login` |
| `Error: not logged in to <url> — run 'packr-cli login'` from `token create` | Same | Same |
| `packr-cli --help` output differs from these docs | Old binary | `packr-cli version`; `doctor` shows it next to the server's version |
| Token expires warning after `cli` startup, or `! credential expiry` in `doctor` | <7 days left | Run `packr-cli rotate --expires <days>` (add `--to-forge-git` for a CI secret) |
| `403` `insufficient permission: requires publish` on a NEW package | Token role lacks the `publish` permission (e.g. `ci-readonly`) | `packr-cli token create --role ci-publish --scope @scope` — **not** `admin`; publishing a new package name needs no admin permission |
| `403` `token not authorized for scope: X` | Token's scopes do not cover `X` | Re-create with a matching `--scope`; the message names the scope it wanted |
| Device flow hangs at `Opening browser...` | Headless machine (no browser) or dashboard unreachable — the live dashboard is otherwise fine | Use `packr-cli login --password` fallback |
| `keychain get: <err>` on macOS | Non-interactive SSH session | `packr-cli config set credential-store file` |
| `Cannot autolaunch D-Bus without X11` on Linux | Headless / no desktop session | `packr-cli config set credential-store file` |
| `Error: maximum of N active tokens for … on the <plan> plan` | Per-plan active-token ceiling reached | Revoke unused tokens (`packr-cli token list` / `token revoke <id>`), or raise the ceiling with `packr-cli token-limit set` (super-admin) |

---

**Next:** [`docs/history/06-dockerfile.md`](history/06-dockerfile.md) — Dockerfile
