Forges
The forge connector, signing in from the CLI with joy forge, where a token is stored per operating system, and how an operator registers a self-hosted instance.
Not in a joy release yet
The newest joy release is v0.20.0, and it carries no joy-forge
binary, no joy forge command group and no pinned host key file. This
page describes the release that brings them, so that it is written down
before it lands. Until that release is out, cargo install joy-cli and
the installers place joy alone, and joy forge is not a command.
joy speaks to GitHub, GitLab, Gitea, Forgejo and Codeberg through a
connector: a separate binary called joy-forge that ships in the same
release archive as joy. All forge knowledge lives there. joy itself
knows no host names, no API shapes and no alias address formats.
There is nothing to configure to get it. cargo install joy-cli builds
joy-forge beside joy, and the installers put both in the same
directory and name both in the install receipt, which is how joy knows
which files it installed. joy update keeps the two in step for a
different reason: it runs the release's own installer, and that one
copies every binary the archive carries.
One binary, three old names
Before this, each forge had its own binary: joy-github, joy-gitlab
and joy-gitea. They still work for the verbs they knew, and a stale
copy beside a fresh joy-forge is harmless, because joy-forge
answers first. joy forge plugins names any stale copy and prints the
rm line for it. joy never deletes a binary it did not install.
Signing in from the CLI
One command group, and every sentence in joy that says "sign in to the forge" points at it:
joy forge login # sign in to the host of this project's remote
joy forge login --host github.com
joy forge login --token-stdin # paste or pipe one token instead
joy forge login --for write # ask for the rights this level needs
joy forge login --login scotty-work
joy forge status # one row per host: login, state, source, scopes, expiry
joy forge logout --host github.com
joy forge logout --all
joy forge plugins # which connector binary answers, and where it isWithout --host, the host is the one joy really contacts for this
project: origin, or the first configured remote.
login runs the forge's own sign in flow and prints the verification URL
and the code while it waits. joy opens no browser for you.
--token-stdin reads exactly one line from standard input instead,
validates it against the forge before storing it, and refuses an empty
line. A token is never a command line argument, so no process list can
carry it, and there is no --token <value>.
status exits 1 when no host in its set is signed in, and login exits
1 on every result other than a successful sign in. Each of the four
commands answers a single {"version":1,"data":{...}} envelope with
--json, and keeps every diagnostic on standard error.
logout removes what joy holds. Where the credential came from gh,
glab or tea, joy removes nothing and prints that CLI's own command
instead, because joy never writes a foreign CLI's store.
The public sign in flows are not live yet
joy's own OAuth applications for github.com, gitlab.com and
codeberg.org are not registered yet, so joy forge login on those
hosts answers with that fact and names the two ways that do work
today: joy forge login --token-stdin, or a client_id for the host
in forges.yaml. Nothing is sent to a forge in the meantime.
Where a token comes from
For one call, in this order, and the answer says which step it was:
- a variable the caller named explicitly. That is the whole answer for that call: if the variable is empty, the call has no credential and nothing else is consulted;
- joy's own entry for that host and login;
- the forge's own environment variables (
GH_TOKEN,GITLAB_TOKEN,GITEA_TOKENand the rest); - the forge CLI, by running it.
Which of your logins answers for a repository is decided in one order too: the pin you set for that host in that project, then the login that last pushed to this remote successfully, then the only login the host holds, then one API call per candidate to see which one can reach the repository. If none can, joy says so by name rather than pushing under whichever account happened to be active.
The pin lives in the project's local app state, never in project.yaml,
because project.yaml is committed and synced to the forge; a pin there
would publish one person's work account to the whole team.
Where joy keeps a token
The connector binary owns the entry and it is the only process that touches it. The app and the CLI both call that same binary instead of reading a store themselves, which on macOS is the only arrangement that avoids a confirmation dialog.
| System | Store | What that means for you |
|---|---|---|
| Windows | Credential Manager | Any process of your logon session can read it. The entry is written with enterprise persistence, so it roams with a roaming profile. |
| macOS | login keychain | Only the binary that created the entry reads it without a prompt. |
| Linux | Secret Service | Used where a session bus and an unlocked collection exist, with persistence switched on so the entry survives a reboot. |
| Linux | kernel keyutils | An in-memory cache only. It does not survive a reboot. |
| Everywhere else | a file joy writes | See below. |
Where the operating system's credential store cannot answer, joy writes
the token itself, to forge-tokens.json in joy's own configuration
directory, with mode 0600 in a directory with mode 0700, and says
"source": "file" rather than pretending otherwise. That is the honest
case on a headless server, in a container and on any machine whose store
refuses. It is the same fallback gh documents for itself: if a
credential store is not found or there is a problem using it, the token
is written to a plain file.
"Cannot answer" is detected, not guessed: after a write the entry is read back through a fresh handle, and a store that cannot return what was just written did not store it.
joy's configuration directory is $XDG_CONFIG_HOME/joy or
~/.config/joy, plus ~/Library/Application Support/joy on macOS and
%APPDATA%\joy on Windows.
Self-hosted instances
A self-hosted forge used to be reachable only when gh, glab or tea
was already signed in to it, which makes the sign in door circular for an
organisation: nobody can sign in through joy because joy does not claim
the host, and joy does not claim the host because nobody signed in.
One file cuts that circle. Put forges.yaml in joy's configuration
directory:
- host: git.acme.test
kind: github # github | gitlab | gitea
api_base: https://git.acme.test/api/v3
web_base: https://git.acme.test
client_id: Iv1.the-app-registered-on-this-instance
device_endpoint: https://git.acme.test/login/device/code
auth_endpoint: https://git.acme.test/login/oauth/authorize
token_endpoint: https://git.acme.test/login/oauth/access_token
scopes: repo user:email
ca_bundle: /etc/pki/tls/certs/acme-root.pem # Linux onlyWith it, the instance is claimed and asked with no forge CLI installed at
all, and the sign in uses the application your own operator registered.
host and kind are the only required keys; everything else defaults to
that forge's own rule for the host. The per-project override
(joy project set forge gitea) keeps working and wins for that project.
What a connector may do
The connector holds its own credential and has named side effects, so it is not a read-only reporter:
- it signs you in, stores the credential and signs you out;
- it answers who you are on that forge, what a repository looks like, and which web address an alias belongs to;
- it creates a repository and publishes a release, over the forge's REST
API and its own HTTPS client. No
curland noghsits on any API path.
Because the connector speaks HTTP itself, everything on What joy reads from your machine about proxies and certificate stores applies to it as well.
Tutorial
Learn terminal-native product management step by step.
Five setups
What to expect on Windows with Git for Windows, on a self-hosted forge behind an internal CA, in an organisation with an OAuth policy and SAML, on a headless Codeberg machine, and at the first contact with a host you have never reached.