@sortlist/cli
v0.53.0
Published
Sortlist platform CLI — GraphQL federation client
Readme
Sortlist CLI (sl)
Command-line interface for the Sortlist platform. Talks to the GraphQL federation endpoint and dynamically discovers available operations via introspection.
Install
npm install -g @sortlist/cli
sl <command>Local development
cd cli
npm install
npx sl <command>Tip: add an alias to your shell profile for convenience:
alias sl='npx sl'Authentication
Uses the Device Authorization Grant flow via Hermes. Tokens are stored at ~/.sortlist/credentials.json.
sl login # opens browser for authentication
sl logout # clears stored credentials
sl whoami # shows current user
sl token # prints access token to stdout (useful for curl/scripts)When commands are missing
The CLI builds its commands from the schema the federation router returns, and the router filters that schema by the scopes in your token. A service you hold no scope for therefore has no commands at all — sl atlas find-organizations reports unknown command, not a permission error.
sl login warns when a service came back with nothing:
⚠ No scope granted for: atlas, billing, core, signals.The cause is a role assignment in the tenant you logged into, not a CLI setting: Auth0 grants requested scopes by exact string match against your assigned permissions and drops the rest silently. Ask an administrator to assign you a role, then run sl login again. sl token shows what you actually hold.
Acting as an application locally
sl use application <id> creates a credential, exchanges it for a token, and revokes the credential immediately. Nothing reusable is stored: what lands in ~/.sortlist/ is a bearer token that expires, and a subgraph verifies it by signature against Hermes' published keys rather than by asking whether the credential still lives.
sl use application <id> # act as it for one token lifetime
sl whoami # confirms: person or application
sl use me # go back to acting as yourselfWhen the token expires, renewing needs your own login again — so this is a local convenience, never a path to unattended use. For that, see below.
Acting for a company
A person can belong to several companies, and a subgraph that counts usage — Atlas records which contact details it hands out — needs to know which one a call is for. Every authenticated request carries that company as an X-Company-Id header, taken from --company or from sl config set company. Both take the company's slug or its GraphQL id, as sl companies lists them; a slug costs one lookup per command, and a raw uuid is refused. The subgraph checks it against the companies your token lists and refuses the request outright when it names one you do not belong to. Without either, no company is named: a staff account is served without anything being counted, anyone else is refused until they pick one. An application belongs to exactly one company, so acting as one needs no header.
sl config set company spin42 # for every command from now on
sl atlas find-contacts --company spin42 … # for one callRevealing contact details
Finding a contact shows only the reach details your company already owns — a name, an email, a phone, a LinkedIn profile each come back empty until revealed — and Atlas says so beside every row. Revealing is explicit, in the same command, with --reveal: a comma-separated list of dimensions named after what they unlock (contactIdentity, contactEmailAddress, contactPhoneNumber, contactLinkedinUrl, and organization… for organizations). Whoever may read contacts may reveal them: nothing beyond atlas/organization:read is needed. Every Atlas command is checked with Hermes first: you must still be a member of the company you act for, or the application must belong to it, otherwise the whole command is refused with a 403 and nothing is recorded — the token's claim was stamped at login and can outlive a membership.
sl atlas find-contacts --page '{"first":5}' --wait \
--fields contacts.nodes.firstName,contacts.nodes.emailAddress # what you own
sl atlas find-contacts --page '{"first":5}' --wait --reveal contactIdentity,contactEmailAddress \
--fields contacts.nodes.firstName,contacts.nodes.emailAddress # reveal, once per windowThe finds are searches: the mutation is acknowledged at once and the page arrives on a subscription, which --wait listens to. Atlas sends a page again as it fills it, and --wait prints its last, COMPLETE, version. Each edge's reveals says what happened to every dimension: REVEALED was recorded by this call, OWNED means the window is still running and nothing was counted again, LOCKED means Atlas has it and nobody asked, UNAVAILABLE means Atlas has nothing, and nothing is counted. The page's credits is the bill: held is the ceiling reserved before the page was read (rows × dimensions asked), used what it actually consumed, one unit per dimension revealed on one row. Nothing is charged yet — these are the figures that would be. Every list argument typed as an enum accepts the same comma-separated shorthand.
Unattended authentication
sl login is interactive and its refresh token expires, so it cannot back a continuous integration job. An application credential can: a client id and secret that every command exchanges for a short-lived token.
# Create a credential — the secret is in the response and shown only once
sl create-application-credential --application-id <id> --name continuous-integration
export SORTLIST_CLIENT_ID=slc_...
export SORTLIST_CLIENT_SECRET=sl_live_...With both set, every command authenticates as the application instead of as you. Managing credentials is the one exception: Hermes refuses its own tokens there, so a leaked secret cannot mint more.
The secret is shown exactly once, because only its digest is stored. Revoking stops new tokens immediately; a token already issued stays valid until it expires.
A credential carries the platform's application scopes, the same for every application and set by nobody: atlas/organization:read, which reads contacts and organizations and reveals them, metered on the application's company. Scopes are additive on this platform, so the token also reaches the baseline — Mercator's geography, the billing plan catalog — while Signals operations and every staff read stay out of reach. Hermes' own management API is not part of what a credential reaches: Hermes refuses the tokens it mints, which is what stops a leaked secret from minting more. The derivation is in backend/hermes/docs/api.md in the platform monorepo, under "What a minted token reaches".
The token endpoint is a different host from the federation endpoint, so it cannot be derived. sl config use-production / use-staging / use-local set both together; sl config set tokenEndpoint <url> sets it on its own.
Federation operations
The CLI offers an operation only when its subgraph tags the root field for it, with @tag(name: "cli") (tags: [{ name: "cli" }] on the Ruby field). Every other query, mutation and subscription stays reachable through the API, but has no command. Exposing one more is a backend change: tag the field, and the command appears within the hour, with no CLI release.
Commands are discovered at runtime through introspection and grouped under the subgraph that serves them. Hermes's account and company operations are the exception and stay at the top level, beside login and config:
sl operations # list them all, grouped by queries, mutations and subscriptions
sl operations --search organization # filter by operation or subgraph name
sl <service> <operation> --help # flags for one operationsl companies
sl atlas find-organizations --help
sl mercator resolve-locations --query brusselsOutput is JSON when piped or with --json, and aligned key/value blocks in a terminal. Ids are never truncated — they are what you paste into the next command — so records are printed as blocks rather than a table.
Each operation supports:
| Flag | Description |
| ------------------- | ------------------------------------------------------------------------------ |
| --depth <n> | Selection depth for nested fields (default: 0); deprecated fields are left out |
| --fields <a,b,c> | Comma-separated fields to include, deprecated ones too when named |
| --exclude <a,b,c> | Comma-separated fields to exclude |
Schema management
The CLI fetches the GraphQL schema via introspection on startup and caches it locally at ~/.sortlist/schema.json (1-hour TTL). To force a refresh:
sl schema refreshThe cache is keyed by endpoint and by a fingerprint of your access token, so switching environments or logging in as someone else re-introspects rather than reusing the previous schema.
Global options
| Flag | Description |
| ---------------------- | --------------------------------------------------------------------------------- |
| --endpoint <url> | Override federation endpoint (default: https://federation.sortlist.com/graphql) |
| --company <slug\|id> | Act for this company; sent as X-Company-Id and checked against your token |
| --json | Force JSON output (auto-enabled when non-TTY) |
| --no-color | Disable colored output |
The endpoint can also be set via the SL_ENDPOINT environment variable or ~/.sortlist/config.json. The default targets production, so point the CLI somewhere else deliberately:
sl config show # what you are currently talking to
sl config use-local # localhost:4020 (federation router) + localhost:8003 (Hermes)
sl config use-staging
sl config use-productionThe three use-* commands and sl config set endpoint exist only when running from a source checkout — a published build always targets production.
State on disk
Everything the CLI remembers lives in ~/.sortlist/. Nothing here is a long-lived secret: the two token files expire on their own, and sl logout removes all three.
| File | Holds | Lifetime |
| -------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------- |
| config.json | endpoint, token endpoint, selected company, last known account | until changed; sl config reset clears it |
| credentials.json | your own access and refresh tokens, mode 0600 | the Auth0 session; sl login replaces it |
| application-session.json | one application token from sl use application, mode 0600 | one token lifetime, then ignored; sl use me removes it |
| schema.json | the introspected schema, keyed by endpoint and a fingerprint of the token | one hour; sl schema refresh forces a refetch |
endpoint and tokenEndpoint describe an environment rather than an identity, so sl logout keeps them and drops the rest.
Exit codes
| Code | Meaning |
| ---- | ------------------------------------------------------------------------------------ |
| 0 | success |
| 1 | bad usage, missing state, or an operation the server refused |
| 2 | not authenticated: no token, an expired one, or a credential that would not exchange |
| 3 | the endpoint could not be reached, or answered with an HTTP error |
| 4 | acting as an application, and the operation belongs to the management API |
Code generation
The only build-time step is npm run codegen, which generates TypeScript types for hand-written operations (auth flow) using graphql-codegen. It reads $SUPERGRAPH_PATH when set, and otherwise ../backend/supergraph.graphql.
npm run generate # alias for npm run codegen
npm run codegen # regenerate TypeScript types for hand-written operationsThis is only needed when:
- The hand-written operations in
src/graphql/operations/change - The schema types referenced by those operations change in the supergraph
All dynamically generated commands (service operations) require no build step — they are derived entirely from runtime introspection.
