Shared core utilities for Doist CLI projects (todoist-cli, twist-cli, outline-cli).
TypeScript, ESM-only, Node ≥ 20.18.1.
npm install @doist/cli-core| Module | Key exports | Purpose |
|---|---|---|
auth (subpath) |
attachLoginCommand, attachLogoutCommand, attachStatusCommand, attachTokenViewCommand, runOAuthFlow, refreshAccessToken, createPkceProvider, createDcrProvider, createSecureStore, createKeyringTokenStore, migrateLegacyAuth, persistBundle, bundleFromExchange, PKCE helpers, AuthProvider / TokenStore / TokenBundle / ActiveBundleSnapshot / RefreshInput / AccountRef / SecureStore / UserRecordStore types, AttachLogoutRevokeContext |
OAuth runtime plus the Commander attachers for <cli> [auth] login / logout / status / token. attachLogoutCommand accepts an optional revokeToken hook for best-effort server-side token revocation. Ships the standard public-client PKCE flow (createPkceProvider), the RFC 7591 Dynamic Client Registration flow (createDcrProvider), a thin cross-platform OS-keyring wrapper (createSecureStore), and a multi-account keyring-backed TokenStore (createKeyringTokenStore) that stores secrets in the OS credential manager and degrades to plaintext in the consumer's config when the keyring is unavailable (WSL/headless Linux/containers). The store contract supports an optional setBundle(account, bundle) write method (required on KeyringTokenStore) so consumers that need refresh-token persistence can opt in via TokenBundle; active() stays narrow (access token + account only) so callers that don't need refresh state don't pay extra keyring IPC. AuthProvider and TokenStore remain the escape hatches for fully bespoke backends (device code, magic-link, …). logout / status / token always attach --user <ref> and thread the parsed ref to store.active(ref) (and store.clear(ref) on logout). commander (when using the attachers), open (browser launch), @napi-rs/keyring (when using createSecureStore or the keyring TokenStore), and oauth4webapi (when a consumer opts into silent refresh or uses createDcrProvider) are optional peer/optional deps. |
commands (subpath) |
registerChangelogCommand, registerUpdateCommand (+ semver helpers) |
Commander wiring for cli-core's standard commands (e.g. <cli> changelog, <cli> update, <cli> update switch). Requires commander as an optional peer-dep. |
config |
getConfigPath, readConfig, readConfigStrict, writeConfig, updateConfig, CoreConfig, UpdateChannel |
Read / write a per-CLI JSON config file with typed error codes; CoreConfig is the shape of fields cli-core itself owns (extend it for per-CLI fields). |
empty |
printEmpty |
Print an empty-state message gated on --json / --ndjson so machine consumers never see human strings on stdout. |
errors |
CliError |
Typed CLI error class with code and exit-code mapping. |
global-args |
parseGlobalArgs, stripUserFlag, createGlobalArgsStore, createAccessibleGate, createSpinnerGate, getProgressJsonlPath, isProgressJsonlEnabled |
Parse well-known global flags (--json, --ndjson, --quiet, --verbose, --accessible, --no-spinner, --progress-jsonl, --user <ref>) and derive predicates from them. stripUserFlag removes --user tokens from argv so the cleaned array can be forwarded to Commander when the flag has no root-program attachment. |
json |
formatJson, formatNdjson |
Stable JSON / newline-delimited JSON formatting for stdout. |
markdown (subpath) |
preloadMarkdown, renderMarkdown, TerminalRendererOptions |
Lazy-init terminal markdown renderer. Requires marked and marked-terminal-renderer as peer-deps — install only if your CLI uses this subpath. |
options |
ViewOptions |
Type contract for { json?, ndjson? } per-command options that machine-output gates derive from. |
spinner |
createSpinner |
Loading spinner factory wrapping yocto-spinner with disable gates. |
terminal |
isCI, isStderrTTY, isStdinTTY, isStdoutTTY |
TTY / CI detection helpers. |
testing (subpath) |
describeEmptyMachineOutput |
Vitest helpers reusable by consuming CLIs (e.g. parametrised empty-state suite covering --json / --ndjson / human modes). |
import { createGlobalArgsStore, createSpinnerGate, createSpinner } from '@doist/cli-core'
const store = createGlobalArgsStore()
export const isJsonMode = () => store.get().json
const shouldDisableSpinner = createSpinnerGate({
envVar: 'TD_SPINNER',
getArgs: store.get,
})
const { withSpinner } = createSpinner({ isDisabled: shouldDisableSpinner })import { printEmpty } from '@doist/cli-core'
if (tasks.length === 0) {
printEmpty({ options, message: 'No tasks found.' })
return
}Install the peer-deps in the consuming CLI:
npm install marked marked-terminal-rendererThen:
import { preloadMarkdown, renderMarkdown } from '@doist/cli-core/markdown'
if (!options.json && !options.raw) {
await preloadMarkdown()
}
console.log(await renderMarkdown(comment.body))If the peer-deps are missing, preloadMarkdown throws a clear error pointing to the install command. TypeScript will also fail to resolve the subpath's types until the peers are installed.
Install the peer-dep in the consuming CLI:
npm install commanderThen wire <cli> changelog in one call:
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { registerChangelogCommand } from '@doist/cli-core/commands'
import packageJson from '../package.json' with { type: 'json' }
registerChangelogCommand(program, {
path: join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'CHANGELOG.md'),
repoUrl: 'https://github.com/Doist/todoist-cli',
version: packageJson.version,
})The helper throws CliError (INVALID_TYPE for a bad --count, FILE_READ_ERROR if the file can't be read) so the CLI's top-level error handler formats and exits.
Wire <cli> update and <cli> update switch similarly:
import { createSpinner, getConfigPath } from '@doist/cli-core'
import { registerUpdateCommand } from '@doist/cli-core/commands'
import packageJson from '../package.json' with { type: 'json' }
const { withSpinner } = createSpinner()
registerUpdateCommand(program, {
packageName: '@doist/todoist-cli',
currentVersion: packageJson.version,
configPath: getConfigPath('todoist-cli'),
changelogCommandName: 'td changelog',
withSpinner,
})update checks the configured channel's npm dist-tag (stable → latest, pre-release → next), compares against currentVersion, and shells out to npm i -g (or pnpm add -g if npm_execpath indicates pnpm). update switch --stable | --pre-release flips the persisted update_channel field via updateConfig, preserving any sibling keys. Both subcommands accept --json / --ndjson. Errors are CliError (INVALID_FLAGS, UPDATE_CHECK_FAILED, UPDATE_INSTALL_FAILED, or the canonical CONFIG_* codes if the config file is broken).
The semver helpers (parseVersion, compareVersions, isNewer, getInstallTag, fetchLatestVersion, getConfiguredUpdateChannel) are also exported for ad-hoc use outside the registered command.
Wire <cli> [auth] login and the supporting OAuth runtime. cli-core ships the standard public-client PKCE flow (createPkceProvider), the RFC 7591 Dynamic Client Registration flow (createDcrProvider), and the attachLoginCommand Commander helper that drives runOAuthFlow end-to-end. Other bespoke flows (device code, magic link, username / password) implement the AuthProvider interface directly — no cli-core release needed. Token storage is a TokenStore the consumer provides; cli-core does not ship a default.
npm install commander opencommander is required when using attachLoginCommand. open is optional. The authorize URL is always surfaced via onAuthorizeUrl (or printed to stdout in human mode, stderr in --json / --ndjson mode) — even when the browser launch succeeds — because the launch can resolve cleanly yet open no actual browser (WSL silent no-op, headless Linux, locked-down corporate envs). WSL hosts get routed through cmd.exe directly so the user's real Windows browser opens. Headless Linux skips the launch entirely and relies on the URL print.
import { attachLoginCommand, createPkceProvider } from '@doist/cli-core/auth'
import type { TokenStore } from '@doist/cli-core/auth'
type Account = { id: string; label?: string; email: string }
const store: TokenStore<Account> = createTokenStore() // see "Implementing TokenStore" below
const provider = createPkceProvider<Account>({
authorizeUrl: ({ handshake }) => `${handshake.baseUrl as string}/oauth/authorize`,
tokenUrl: ({ handshake }) => `${handshake.baseUrl as string}/oauth/token`,
clientId: ({ flags }) => flags.clientId as string,
validate: async ({ token, handshake }) => probeUser(token, handshake.baseUrl as string),
})
const auth = program.command('auth')
attachLoginCommand<Account>(auth, {
provider,
store,
preferredPort: 54969,
portFallbackCount: 5,
resolveScopes: ({ readOnly }) => (readOnly ? ['read'] : ['read', 'write']),
renderSuccess: () => `<html>...</html>`,
renderError: (message) => `<html>${message}</html>`,
onSuccess: ({ account, view }) => {
if (view.json) console.log(JSON.stringify({ account }))
else console.log(`Signed in as ${account.label ?? account.id}`)
},
}).description('Authenticate via OAuth')attachLoginCommand returns the new Command so you can chain .description(...) / .option(...) / .addHelpText(...). Any consumer-attached options land in the flags object passed to resolveScopes, onSuccess, and the provider hooks.
The authorizeUrl / tokenUrl / clientId resolvers may return string or Promise<string> — so a consumer can resolve the base URL or client id asynchronously (reading config, prompting the user) without abandoning createPkceProvider. An injected fetchImpl is used for the token exchange and the refresh grant (threaded into oauth4webapi via its customFetch), so a custom transport — proxy dispatcher, decompression — applies on every OAuth call rather than being bypassed by the library's global fetch.
For providers that issue per-install client_id / client_secret via RFC 7591. createDcrProvider registers in prepare(), then drives the standard PKCE authorize / token-exchange dance against the resulting client. Registration and token exchange run through oauth4webapi (the same optional peer dep PKCE refresh uses — npm install oauth4webapi), so endpoints must be HTTPS and the registration endpoint must return RFC 7591-conformant 201 responses.
import { attachLoginCommand, createDcrProvider } from '@doist/cli-core/auth'
const provider = createDcrProvider<Account>({
registrationUrl: 'https://example.com/oauth/register',
authorizeUrl: 'https://example.com/oauth/authorize',
tokenUrl: 'https://example.com/oauth/token',
clientMetadata: {
clientName: 'Example CLI',
clientUri: 'https://github.com/example/cli',
logoUri: 'https://example.com/logo.png',
applicationType: 'native',
tokenEndpointAuthMethod: 'client_secret_basic', // default
},
validate: async ({ token }) => probeUser(token),
})The DCR-issued client_id (and client_secret, if returned) are stashed in the handshake and threaded through the rest of the flow. The server-returned token_endpoint_auth_method is authoritative (RFC 7591 §3.2.1) and overrides the configured one. By default the token exchange uses Authorization: Basic with each credential component percent-encoded via encodeURIComponent (RFC 3986) rather than oauth4webapi's stricter RFC 6749 §2.3.1 form-url-encoding. Both escape genuinely reserved chars (: / % / + / /) so a conformant server reconstructs them, but §2.3.1 also escapes the unreserved - _ . ~ — which breaks servers that don't url-decode the Basic credential (a DCR-issued twd_… would arrive as twd%5F… and miss the lookup). Pass tokenEndpointAuthMethod: 'client_secret_post' to send credentials in the body instead, or 'none' for a public-client registration. When the registration response carries no client_secret, the exchange falls back to a public-client POST regardless of the requested method. Any extra registration metadata (e.g. software_statement) goes in clientMetadata.extra. cli-core does not cache the registered client — each login mints a fresh one.
Both createPkceProvider and createDcrProvider accept an optional errorHints: string[] that is prepended to every CliError they throw. Use it for CLI-specific remediation that should accompany every auth failure (e.g. ['Try again: tw auth login', 'Or set TWIST_API_TOKEN environment variable']). Server-returned response bodies (for non-2xx replies) are appended after the user hints so the actionable hint stays at the top.
The same registrar shape covers the other three auth subcommands. Each returns the new Command for chaining and shares the same TokenStore<TAccount> instance.
import {
attachLogoutCommand,
attachStatusCommand,
attachTokenViewCommand,
} from '@doist/cli-core/auth'
attachLogoutCommand<Account>(auth, {
store,
revokeToken: async ({ token }) => {
// Optional pre-clear server-side revocation. Errors are swallowed so
// local logout always succeeds — surface diagnostics via your own
// logging if you need them.
await api.revokeToken(token)
},
onCleared: ({ account, view }) => {
// Optional follow-up — surface keyring-fallback warnings, etc.
// Route extra prose to stderr in machine-output mode.
if (!view.json && !view.ndjson && account) {
console.log(`Cleared credential for ${account.label ?? account.id}.`)
}
},
})
attachStatusCommand<Account>(auth, {
store,
fetchLive: async ({ token }) => probeUser(token), // throws CliError on 401
renderText: ({ account }) => [
`Signed in as ${account.label ?? account.id}`,
` Email: ${account.email}`,
],
renderJson: ({ account }) => ({ id: account.id, email: account.email }),
})
attachTokenViewCommand<Account>(auth, {
store,
envVarName: 'TODOIST_API_TOKEN', // refuse to print when the env var is populated
})attachLogoutCommand snapshots store.active(ref) when either --user <ref> is supplied or one of the consumer hooks (revokeToken / onCleared) needs the prior account, calls store.clear(ref), awaits revokeToken({ token, account, ref, view, flags }) for best-effort server-side revocation, emits ✓ Logged out (human) or { "ok": true } (--json, silent under --ndjson), and finally fires onCleared({ account, ref, view, flags }). ref is the parsed --user argument (or undefined) so consumers can distinguish "nothing was stored" (account: null, ref: undefined) from "cleared an unreadable record by ref" (account: null, ref: "me"). revokeToken failures are always swallowed; the pre-flight snapshot's error contract is covered in the --user <ref> section below. The exported AttachLogoutRevokeContext<TAccount> is the ctx type for typing standalone revoke implementations.
attachStatusCommand reads the active credential — preferring store.activeBundle when fetchLive is supplied, so the access token and the full bundle come from a single keyring read — optionally runs fetchLive (consumer translates 401 → CliError('NO_TOKEN', …)), then dispatches to renderJson (--json / --ndjson via formatJson / formatNdjson, defaults to the account itself, only invoked in machine-output mode) or renderText (human mode, string or array of lines). When the store is empty it throws CliError('NOT_AUTHENTICATED', 'Not signed in.') unless onNotAuthenticated is supplied. fetchLive receives { account, token, bundle?, view, flags } — bundle carries the refresh-side metadata (expiry, refresh token) when the store implements activeBundle, so a consumer can render expiry without a second read.
Both attachers strip the standard --json / --ndjson / --user registrar flags from the parsed options and pass the remainder to their callbacks as flags — same escape hatch attachLoginCommand uses, so a consumer can chain e.g. .option('--full') and read it in revokeToken / onCleared / renderText / fetchLive / renderJson / onNotAuthenticated.
attachTokenViewCommand writes the bare stored token to stdout (no envelope, pipe-safe) and appends a trailing newline only when stdout is a TTY. When envVarName is set and the env var is populated, it throws CliError('TOKEN_FROM_ENV', …) to avoid disclosing a token the CLI did not manage. Defaults to subcommand name token; pass name: 'view' to nest under an existing token group.
attachLoginCommand registers four flags on the login subcommand:
| Flag | Effect |
|---|---|
--read-only |
Threaded through to resolveScopes and the provider hooks via readOnly. |
--callback-port <port> |
Override preferredPort per invocation. Validated as [0..65535]; 0 = OS-assigned. |
--json |
Machine-output mode. Authorize-URL print is routed to stderr. |
--ndjson |
Machine-output mode. Same print routing. |
Under --json / --ndjson, the always-printed authorize URL goes to stderr so the JSON / NDJSON envelope on stdout stays clean. Pass onAuthorizeUrl to override the destination. The success / error HTML returned by renderSuccess / renderError is a render hook — every CLI brings its own template (no shared layout enforced).
TokenStore is uniformly multi-user-shaped — single-user CLIs implement list / setDefault trivially against their one stored account. There is no separate single-user contract.
import { CliError, getConfigPath, readConfig, updateConfig, writeConfig } from '@doist/cli-core'
import type { AccountRef, TokenStore } from '@doist/cli-core/auth'
type Account = { id: string; label?: string; email: string }
type StoredAuth = { account: Account; token: string }
const configPath = getConfigPath('outline-cli')
function matches(account: Account, ref: AccountRef): boolean {
return account.id === ref || account.label === ref
}
// Single-user impl. Honour `ref` on every method — returning null on a miss
// is what lets the attachers translate `<cmd> --user wrong` into a typed
// `ACCOUNT_NOT_FOUND` error instead of a misleading success or
// `NOT_AUTHENTICATED`.
export const tokenStore: TokenStore<Account> = {
async active(ref?: AccountRef) {
const config = await readConfig<{ auth?: StoredAuth }>(configPath)
if (!config.auth) return null
if (ref !== undefined && !matches(config.auth.account, ref)) return null
return { account: config.auth.account, token: config.auth.token }
},
async set(account, token) {
await updateConfig<{ auth: StoredAuth }>(configPath, { auth: { account, token } })
},
async clear(ref?: AccountRef) {
const config = await readConfig<{ auth?: StoredAuth }>(configPath)
if (!config.auth) return
if (ref !== undefined && !matches(config.auth.account, ref)) return
const { auth: _drop, ...rest } = config
await writeConfig(configPath, rest)
},
async list() {
const snapshot = await this.active()
return snapshot ? [{ account: snapshot.account, isDefault: true }] : []
},
async setDefault(ref: AccountRef) {
const snapshot = await this.active()
if (!snapshot || !matches(snapshot.account, ref)) {
throw new CliError('ACCOUNT_NOT_FOUND', `No stored account matches "${ref}".`)
}
// Single-user store — already the default.
},
}For multi-account storage (OS keychain, per-user config slots, …), implement the same five methods against your backend and honour ref on active / clear / setDefault. AccountRef is an opaque string — cli-core does not constrain matching semantics (id-exact, email-case-insensitive, label, …). The store impl owns that.
Stores that target servers issuing refresh tokens may implement the optional setBundle(account, bundle, options?) method. TokenBundle carries { accessToken, refreshToken?, accessTokenExpiresAt?, refreshTokenExpiresAt? }. Stores that omit setBundle continue to work — cli-core helpers (persistBundle) fall back to set(account, bundle.accessToken) and silently drop refresh state. KeyringTokenStore implements setBundle as a required override and routes the refresh token to a sibling keyring slot.
active() stays narrow — { token, account } only — so commands that only need the access token don't pay extra keyring IPC. The companion read method activeBundle?(ref) returns the full bundle ({ account, bundle }) and is the read path the silent-refresh helper requires. Optional on the contract; KeyringTokenStore implements it as a required override and parallel-reads both keyring slots, honouring the hasRefreshToken: false record gate to skip the refresh-slot IPC on access-only records.
The persistBundle({ store, account, bundle, promoteDefault? }) helper is the recommended write path for bundle-capable consumers — it prefers setBundle when available and falls back to set otherwise (the set fallback can't honour promoteDefault, so multi-account stores wanting silent-refresh-safe selection must implement setBundle). runOAuthFlow persists the full bundle through persistBundle with promoteDefault: true.
refreshAccessToken({ store, provider, lockPath, skewMs?, force?, ref?, handshake? }) rotates the access token using the stored refresh token. Use proactively before each authenticated call (skew defaults to 60s) or reactively with force: true after a 401. Persists the rotated bundle via persistBundle without promoteDefault so a background rotation can't re-pin account selection. handshake (default {}) is forwarded to provider.refreshToken for resolvers that need runtime context (e.g. a --env-derived base URL).
lockPath is caller-provided (cli-core doesn't interpret ~ or know where your config lives) — O_EXCL on that path serialises concurrent CLI invocations so only one POSTs and the rest re-read the rotated bundle. Recommended path: ${getConfigPath(serviceName)}.refresh.lock.
Error contract:
AUTH_REFRESH_EXPIRED— server rejected the refresh token (invalid_grant, including 400 and 401 since some reverse proxies remap). Caller should prompt re-login.AUTH_REFRESH_TRANSIENT— 5xx, network, non-JSON body, lock timeout. Caller may retry.AUTH_REFRESH_UNAVAILABLE— refresh isn't possible in the current setup: no refresh token stored, the store doesn't implement bothactiveBundleandsetBundle(a full bundle must be readable and persistable), the credential was removed mid-refresh, the provider doesn't implementrefreshToken, or the optionaloauth4webapipeer dep isn't installed.
The PKCE provider (createPkceProvider) implements refreshToken via the oauth4webapi library, declared as an optional peer dependency — only CLIs that opt into refresh or use createDcrProvider need to install it (npm install oauth4webapi). Providers built directly against the AuthProvider interface (e.g. device code) implement the refreshToken? hook themselves; the storage and helper contract is identical.
When the OS credential manager is the right place for your token, createSecureStore is a thin cross-platform wrapper around @napi-rs/keyring. It exposes a three-method handle (getSecret / setSecret / deleteSecret) for one slot identified by serviceName + account:
import { createSecureStore, SecureStoreUnavailableError } from '@doist/cli-core/auth'
const slot = createSecureStore({ serviceName: 'todoist-cli', account: 'api-token' })
try {
await slot.setSecret(token)
} catch (error) {
if (error instanceof SecureStoreUnavailableError) {
// Keyring unreachable (WSL without D-Bus, missing native binary on an
// exotic arch, Keychain locked, …). Fall back to wherever your CLI
// stores fallback state — config file with a clear plaintext warning,
// an env-var prompt, etc.
} else {
throw error
}
}Every failure mode — @napi-rs/keyring failing to load on an arch without a prebuilt binary, libsecret not running on a headless Linux box, the Keychain prompting and the user denying — is normalised into SecureStoreUnavailableError. The @napi-rs/keyring module is dynamic-imported so a missing native binary doesn't crash module load before the error can surface.
@napi-rs/keyring is declared in cli-core's own optionalDependencies, so npm pulls it in transitively when you install @doist/cli-core — your consumer CLI does not need to add it explicitly. The library ships pre-built native binaries for Windows (Credential Manager), macOS (Keychain), and Linux glibc + musl (libsecret / Secret Service).
createKeyringTokenStore wires createSecureStore into the TokenStore contract for multi-account CLIs. Secrets live in the OS credential manager; per-user metadata stays in the consumer's config via a small UserRecordStore port the consumer implements. When the keyring is unreachable the store transparently falls back to a fallbackToken field on the user record and exposes a warning on getLastStorageResult() for the login command to surface.
import { createKeyringTokenStore, type UserRecordStore } from '@doist/cli-core/auth'
type Account = { id: string; label?: string; email: string }
// Adapter over the consumer's existing config.json shape.
const userRecords: UserRecordStore<Account> = {
async list() {
/* read from config */
},
async upsert(record) {
/* replace, do not merge — see UserRecordStore docs */
},
async remove(id) {
/* … */
},
async getDefaultId() {
/* … */
},
async setDefaultId(id) {
/* … */
},
describeLocation() {
return '~/.config/todoist-cli/config.json'
},
}
export const tokenStore = createKeyringTokenStore<Account>({
serviceName: 'todoist-cli',
userRecords,
})
// In your login command's onSuccess:
const storage = tokenStore.getLastStorageResult()
if (storage?.warning) console.error('Warning:', storage.warning)The returned store satisfies the full TokenStore contract — including list() / setDefault(ref) / ref-aware active / clear — so it plugs straight into the logout / status / token attachers. Default ref matching is account.id === ref || account.label === ref; override matchAccount to broaden it (e.g. case-insensitive email).
When a matching record exists but the keyring read fails, active(ref) throws CliError('AUTH_STORE_READ_FAILED', …). attachLogoutCommand catches it specifically so logout --user <ref> still clears the local record even with the keyring offline; status / token-view propagate it because they can't render without the token.
For sync/lazy-decrypt or fully bespoke backends, implement TokenStore directly.
For one-time migration of a v1 single-user token into the v2 multi-user shape, use migrateLegacyAuth from a postinstall hook. The helper requires a durable migration marker the consumer owns — a boolean persisted in their config — so the migration is genuinely one-way: a later logout (which empties userRecords) followed by a reinstall won't re-migrate a stale legacy token.
import { getConfigPath, readConfig, updateConfig } from '@doist/cli-core'
import { migrateLegacyAuth } from '@doist/cli-core/auth'
const configPath = getConfigPath('todoist-cli')
const result = await migrateLegacyAuth<Account>({
serviceName: 'todoist-cli',
legacyAccount: 'api-token',
userRecords,
// Durable one-way gate. Persist `migrated_v2: true` in your config
// after a successful migration; check it on every run.
hasMigrated: async () =>
(await readConfig<{ migrated_v2?: boolean }>(configPath)).migrated_v2 === true,
markMigrated: async () =>
updateConfig<{ migrated_v2: boolean }>(configPath, { migrated_v2: true }),
loadLegacyPlaintextToken: async () =>
(await readConfig<{ api_token?: string }>(configPath)).api_token ?? null,
identifyAccount: async (token) => fetchUser(token),
cleanupLegacyConfig: async () => clearLegacyAuthFields(configPath),
silent: true,
logPrefix: 'todoist-cli',
})
if (result.status === 'skipped' && result.reason === 'legacy-keyring-unreachable') {
// Retryable — the next postinstall run with the keyring online will
// pick up where this one left off.
}MigrateAuthResult is a discriminated union on status ('already-migrated' | 'no-legacy-state' | 'migrated' | 'skipped'). migrated carries the resolved account; skipped carries a stable reason ('identify-failed' | 'legacy-keyring-unreachable' | 'user-record-write-failed' | 'marker-write-failed') plus a free-form detail.
The helper is best-effort throughout: any failure (offline keyring, network error fetching the user, upsert blip) leaves the v1 state untouched so the consumer's runtime fallback can keep serving the legacy token until the next attempt. markMigrated() is called before the legacy keyring delete + cleanupLegacyConfig, so cleanup failures can't cause re-migration on the next run — the marker is the one-way gate, not cleanup success. The legacy delete and cleanupLegacyConfig run concurrently via Promise.allSettled. stderr output uses fixed phrases keyed off MigrateSkipReason and the success log omits the account identifier entirely so consumer-supplied error text (and PII-shaped account.id values like emails) can't leak into logs.
The three account-touching attachers (attachLogoutCommand / attachStatusCommand / attachTokenViewCommand) always attach --user <ref> on their subcommand. attachLogoutCommand threads the parsed ref to both store.active(ref) and store.clear(ref); attachStatusCommand and attachTokenViewCommand only call store.active(ref). When --user is supplied but store.active(ref) returns null, each attacher throws CliError('ACCOUNT_NOT_FOUND', …) so the user sees a typed miss rather than NOT_AUTHENTICATED or a silent ✓ Logged out. Single-user stores returning null for a non-matching ref is the supported way to feed this guard.
For pre-subcommand --user (<cli> --user alice some-cmd) that should apply to non-auth commands too, parse it globally and strip from argv before handing to Commander:
import { parseGlobalArgs, stripUserFlag } from '@doist/cli-core'
const args = parseGlobalArgs(process.argv.slice(2))
console.log(args.user) // 'alice' | undefined
await program.parseAsync([
process.argv[0],
process.argv[1],
...stripUserFlag(process.argv.slice(2)),
])Account-selection resolvers (env > --user > default > single-only > error), account list, and account use subcommands stay per-CLI for now — cli-core ships only the contract until at least one consumer has shipped these end-to-end.
ACCOUNT_NOT_FOUND is thrown by the account-touching attachers when --user <ref> was supplied but store.active(ref) returned null. NO_ACCOUNT_SELECTED is reserved for consumer-thrown resolver failures (multiple accounts stored, no default, no --user); cli-core does not throw it itself.
A TokenStore MAY throw CliError('AUTH_STORE_READ_FAILED', …) from active(ref) when a matching record exists but the token itself can't be read (e.g. an OS keyring backing the store is offline). attachLogoutCommand catches this specific code on the explicit-ref path and proceeds with clear(ref) — local logout doesn't need the token, and the revokeToken hook is skipped because there's no token to send. Every other error from active(ref) (notably ACCOUNT_NOT_FOUND from a genuine ref miss, plus any consumer-thrown code) still propagates so a real miss isn't masked. Without --user, the logout pre-flight swallows any snapshot read failure so the local clear always runs. attachStatusCommand and attachTokenViewCommand propagate AUTH_STORE_READ_FAILED since they have no way to render or print without the token.
Implement AuthProvider directly for device code, magic-link, username / password, or any other flow not covered by createPkceProvider / createDcrProvider. The four hooks fire in this order during runOAuthFlow:
| Hook | When | Purpose |
|---|---|---|
prepare? |
Before the callback server binds | Pre-flight (e.g. DCR to mint a client_id). The returned handshake is threaded into every later hook. |
authorize |
After the callback server is up | Build the URL the user opens. Returns the URL plus any handshake state needed at exchange (PKCE verifier, …). |
exchangeCode |
After the browser callback fires | Trade the code for an accessToken. Optionally returns a fully-resolved account to skip validateToken. |
validateToken |
When exchangeCode had no account |
Probe an authenticated endpoint to resolve the account. |
Skeleton:
import type { AuthProvider } from '@doist/cli-core/auth'
const provider: AuthProvider<Account> = {
async prepare({ redirectUri, flags }) {
const { clientId } = await registerClient(redirectUri)
return { handshake: { clientId } }
},
async authorize({ redirectUri, state, scopes, handshake }) {
const url = buildAuthorizeUrl(handshake.clientId as string, redirectUri, state, scopes)
return { authorizeUrl: url, handshake }
},
async exchangeCode({ code, redirectUri, handshake }) {
const { access_token } = await postToken(handshake, code, redirectUri)
return { accessToken: access_token }
},
async validateToken({ token }) {
return probeUser(token)
},
}The handshake is shared mutable state across hooks. runOAuthFlow folds the runtime flags and readOnly values into the handshake before exchangeCode and validateToken, so resolvers see the same view they had at authorize time without you having to re-thread them.
Every failure in this subpath surfaces as a CliError:
| Code | Cause |
|---|---|
AUTH_OAUTH_FAILED |
Provider returned ?error=..., the flow was aborted via signal, or the callback server stopped before completion. |
AUTH_CALLBACK_TIMEOUT |
No valid callback within timeoutMs (default 3 minutes). |
AUTH_PORT_BIND_FAILED |
Could not bind any port in [preferredPort, preferredPort + portFallbackCount], or --callback-port was out of range. |
AUTH_DCR_FAILED |
createDcrProvider registration failed (network error, non-201, non-JSON body, response missing client_id, or the oauth4webapi peer dep isn't installed). |
AUTH_TOKEN_EXCHANGE_FAILED |
Token endpoint network error, non-2xx response, non-JSON body, or missing access_token. |
AUTH_STORE_WRITE_FAILED |
TokenStore.set threw a non-CliError. (CliErrors thrown from set propagate unchanged.) |
NOT_AUTHENTICATED |
status / token ran with an empty TokenStore (and no onNotAuthenticated callback for status). Default message: 'Not signed in.'. |
TOKEN_FROM_ENV |
attachTokenViewCommand refused to print: envVarName was set and the env var is populated. |
NO_ACCOUNT_SELECTED |
Reserved for consumer-thrown resolver failures when multiple accounts are stored without a default and no --user was supplied. |
ACCOUNT_NOT_FOUND |
logout / status / token were invoked with --user <ref> but store.active(ref) returned null. Also reserved for consumer resolvers when a ref doesn't match any stored account. |
The consumer's top-level error handler formats and exits.
For custom Commander wiring (different command name, programmatic invocation, embedding in a non-Commander host) call runOAuthFlow directly with the same option set attachLoginCommand builds internally:
import { runOAuthFlow } from '@doist/cli-core/auth'
const result = await runOAuthFlow({
provider,
store,
scopes: ['read', 'write'],
readOnly: false,
flags: {},
preferredPort: 54969,
renderSuccess: () => `<html>...</html>`,
renderError: (message) => `<html>${message}</html>`,
})
console.log(result.account)Pass signal (an AbortSignal) to wire Ctrl-C cancellation; pass timeoutMs, callbackPath, or callbackHost to override the defaults (3 min / /callback / 127.0.0.1).
For AuthProvider implementations that need RFC 7636 helpers without going through createPkceProvider:
generateVerifier({ length?, alphabet? })— RFC 7636 verifier (43–128 chars, default 64). Pass a customalphabetto match a specific server's canonicalisation (Todoist drops.and~).deriveChallenge(verifier)—base64url(sha256(verifier)), the S256code_challenge.generateState()— 128-bit hex CSRF token suitable for the OAuthstateparameter.DEFAULT_VERIFIER_ALPHABET— the 66-char RFC 7636 unreserved set.
npm install
npm run build
npm test
npm run check # oxlint + oxfmt --check (PR gate)
npm run fix # oxlint --fix + oxfmtSee AGENTS.md for project conventions, including the rule that this README is kept in sync with public API changes.
MIT