Sign a command-line tool in to Hraness Accounts and keep its refresh token between runs.
The package uses the OAuth 2.0 device authorization grant: your CLI prints a link and a code, the user approves the sign-in in a browser, and the CLI receives an access token and a refresh token. A session object then stores the refresh token and exchanges it for fresh access tokens when the old one expires.
The package is not on npm. Pin a release tag:
{ "dependencies": { "@hraness/accounts-cli": "github:hraness/accounts-cli#v0.3.0" } }The built dist/ is committed, so installing from GitHub needs no build step.
This release depends on @hraness/suite-accounts v0.9.13.
Use the macOS keychain for a CLI that needs sign-in to survive a restart on
macOS. For a portable first integration or a test, use
createMemoryTokenStorage(); the refresh token disappears when the process
ends. Encrypted file storage works without the keychain, but its key is derived
from non-secret machine information. Do not treat it as protection from another
process running as your user. See Token storage for failure
handling and platform behavior.
Accounts assigns each product a client ID and the device authorization,
device token, and token endpoints. @hraness/suite-accounts exposes the
endpoints in its provider configuration.
import {
createCliSession,
createKeychainTokenStorage,
initiateDeviceLogin,
pollForDeviceToken,
renderDeviceLogin,
renderDeviceLoginResult,
} from "@hraness/accounts-cli";
const login = await initiateDeviceLogin(
{ deviceAuthorizationEndpoint, deviceTokenEndpoint },
{ clientId, scopes: ["openid", "profile", "email", "offline_access"] },
{
onUserCode: (code, url) => process.stderr.write(renderDeviceLogin({
product: "My CLI", url, code, complete: url.includes(code),
})),
},
);
const result = await pollForDeviceToken(
deviceTokenEndpoint,
{ clientId, deviceCode: login.deviceCode },
{ intervalMs: login.intervalMs },
);
const session = createCliSession({
clientId,
storage: createKeychainTokenStorage("my-cli", "default", { product: "My CLI" }),
tokenEndpoint,
});
if (result.kind === "token") await session.saveRefreshToken(result.refreshToken);
process.stderr.write(renderDeviceLoginResult(result, { product: "My CLI", loginCommand: "my-cli login", next: "my-cli status" }));
const accessToken = await session.getAccessToken(); // null when signed outWhile it waits, renderDeviceLogin prints:
→ Sign in to My CLI: https://accounts.hraness.com/device?code=WDJB-MJHT
Check the code in your browser matches: WDJB-MJHT
Waiting for approval… (Ctrl-C to cancel)
renderDeviceLoginResult then prints one line and one next step:
✓ Signed in to My CLI. / Next: my-cli status, or, for example,
✗ Sign-in was declined in the browser. / → my-cli login. Both fall back to
ASCII (->, OK, FAIL) when TERM=dumb, the locale is not UTF-8, or
HRANESS_ASCII=1, and strip control characters from server text. Pass an
openBrowser(url) handler to initiateDeviceLogin to open the page yourself;
this package never opens a browser.
initiateDeviceLogin returns the user code, the verification URL, the device
code, the polling interval, and a poll() function that makes one token
request. pollForDeviceToken keeps polling until the user approves or denies
the request, the code expires, or its timeout passes. It polls every five
seconds for up to ten minutes by default, and a slow_down answer switches to
the interval the server asks for. It returns one of:
kind |
Meaning |
|---|---|
token |
Signed in. Carries accessToken and refreshToken. |
access_denied |
The user declined. |
expired_token |
The device code expired before approval. |
error |
Any other failure, with error and errorDescription. A timeout reports error: "timeout"; a token response without a refresh token reports invalid_response. |
| Result | Next action |
|---|---|
access_denied |
Start a new login only when you want to approve it in the browser. |
expired_token or error: "timeout" |
Start a new login to get a fresh code; do not reuse the expired device code. |
| Keychain error | Show renderKeychainError(error) and follow its next step. Do not erase the stored token or report a sign-out. |
getAccessToken() returns null |
Ask for sign-in again before calling your authenticated API. |
getAccessToken() throws |
Handle the network, JSON, or storage failure separately from a signed-out state. |
Never print either token to confirm success. Use the rendered sign-in result, then make an authenticated request to your product's API. The package handles tokens; your CLI decides which API to call and how to display its result.
createCliSession keeps the access token in memory and the refresh token in the
storage you choose. getAccessToken() returns the cached access token until it
expires, then refreshes it with the stored refresh token and saves any new
refresh token the server returns. It returns null when no refresh token is
stored, when the token endpoint answers with an error status, or when the
response lacks an access token or a positive expires_in. A network failure or
a non-JSON response throws. signOut() clears the cached token and deletes the
stored refresh token; it does not revoke the token with Accounts, so say
"signed out on this device" (renderSignedOut(product)).
| Function | Where the refresh token lives |
|---|---|
createKeychainTokenStorage(service, account, { product }) |
The macOS login keychain, through /usr/bin/security. The item is labeled {product} sign-in with a comment that says what it is and that deleting it signs out on this Mac. The token never appears in a process argument list: it goes to security -i on stdin, hex-encoded. On other platforms saving throws Keychain storage is only supported on macOS., and reading returns null. |
createEncryptedFileTokenStorage(path) |
A file written with mode 0600 and encrypted with AES-256-GCM. The key is derived from the host name, user name, platform, and home directory, which are not secret, so the file permissions are the main protection. |
createMemoryTokenStorage() |
Memory only, for tests and single-run tools. |
Reading from the keychain returns null only when there is no item. A locked
keychain, a denied access prompt, or any other security failure throws a
KeychainError with a code (keychain-locked, keychain-denied,
keychain-unavailable, or keychain-write-failed for saves), a one-sentence
message, and one next step such as Unlock your login keychain, then try again. Deleting throws the same way when the item stays in place, so
signOut() never reports a sign-out that didn't happen.
renderKeychainError(error) prints both lines. Products should show
it rather than treat the person as signed out.
accountVerbs(options) returns two verbs for a desktop-foundation registry,
so every product answers account status and account signout the same way:
import { HranessError, defineRegistry } from "@hraness/desktop-foundation/registry";
import { accountVerbs } from "@hraness/accounts-cli";
const registry = defineRegistry("ghostget", [
...productVerbs,
...accountVerbs({
product: "ghostget",
displayName: "Ghostget",
signInCommand: "ghostget login",
readState: async () => ({ kind: "signedIn", account: "ben@example.com" }),
signOut: () => session.signOut(),
usageError: (message) => new HranessError("usage", message),
}),
]);account status is a read verb. With --json it prints a
<product>.account/1 envelope whose data is { state, account?, detail?, signIn? }: state is signedIn, signedOut, expired or locked, and
signIn holds the sign-in command when the person needs it. A locked keychain
is locked, not signed out. account signout is an operate verb that calls
your signOut and prints { "signedOut": true } under
<product>.account-signout/1; if signOut throws, the command fails and
reports no sign-out. Sign-in stays the product's own command, because the
person approves it in the browser. accountStatus(state, signInCommand)
returns the same data for products that build their own status verb.
usageError is required. This package doesn't depend on desktop-foundation,
so it can't build the registry's HranessError itself; pass
(message) => new HranessError("usage", message) so extra arguments answer
usage (exit 2) rather than internal (exit 1). The verbs match the
registry's Verb shape. Menu rows (accountMenuItems) are gone with the menu bar companions.
bun install --frozen-lockfile
bun run checkbun run check runs lint, typecheck, tests, and the build.