From 154488f59ea5123ee0f67ddd93598f1e7e6a07f2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 20:19:38 +0200 Subject: [PATCH 1/4] feat(cli): add polymorfa doctor Checks Node.js version, profile config, keyring access, API reachability via /ping, and server API key validity via an authenticated project list. --- README.md | 20 +++ src/commands/doctor.ts | 50 +++++++ src/services/doctor.ts | 257 +++++++++++++++++++++++++++++++++++ test/services/doctor.test.ts | 156 +++++++++++++++++++++ 4 files changed, 483 insertions(+) create mode 100644 src/commands/doctor.ts create mode 100644 src/services/doctor.ts create mode 100644 test/services/doctor.test.ts diff --git a/README.md b/README.md index d65c3516..2c2d0a93 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,26 @@ rejects any other project, and a command fails if the directory is linked to a different project. `polymorfa listen` falls back to the linked or profile project when `--project` is omitted. +## Doctor + +```shell +polymorfa doctor +polymorfa doctor --profile work --json +``` + +`doctor` runs five checks and exits with code 1 if any fails: + +| Check | What it does | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `node` | Requires Node.js 22 or newer. | +| `config` | Loads and validates the profile config and reports the active profile. | +| `keyring` | Reads the selected profile's entry from the operating-system keyring. A locked or unavailable keyring fails. | +| `api` | Calls the unauthenticated `/ping` on the configured base URL, without retries. | +| `credential` | For a server API key, lists projects as an authenticated ping. `401` fails; `403` warns that the key is valid but lacks `sessions:read`. Project and client tokens are reported as present but are not validated remotely. | + +Environment credentials take precedence over the active profile, as for other +commands. Output never contains credential values. + ## QuickLinks A QuickLink is a persistent hosted link that connects one WhatsApp number to a diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 00000000..11a66257 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,50 @@ +import {BaseCommand} from '../base-command.js' +import {SecureCredentialStore} from '../config/credentials.js' +import {ProfileRepository} from '../config/profiles.js' +import {runDoctor} from '../services/doctor.js' + +export default class Doctor extends BaseCommand { + static override description = + 'Check Node.js, config, keyring access, API reachability, and API key validity.' + + static override examples = [ + '<%= config.bin %> doctor', + '<%= config.bin %> doctor --profile work --json', + ] + + async run(): Promise { + const {flags} = await this.parse(Doctor) + const credentials = new SecureCredentialStore() + const report = await runDoctor({ + credentials: { + ...credentials, + delete: (account) => credentials.delete(account), + get: async (account) => { + const value = await credentials.get(account) + if (value !== undefined) this.registerSecret(value) + return value + }, + set: (account, value) => credentials.set(account, value), + }, + env: process.env, + profile: flags.profile, + profiles: new ProfileRepository(), + timeoutMs: flags.timeout, + }) + for (const value of [ + process.env.POLYMORFA_API_KEY, + process.env.POLYMORFA_PROJECT_TOKEN, + process.env.POLYMORFA_CLIENT_TOKEN, + ]) { + if (value !== undefined) this.registerSecret(value) + } + const human = report.checks + .map( + (check) => + `${check.status.toUpperCase().padEnd(4)} ${check.name.padEnd(10)} ${check.detail}`, + ) + .join('\n') + this.writeValue(report, human, flags) + if (!report.ok) process.exitCode = 1 + } +} diff --git a/src/services/doctor.ts b/src/services/doctor.ts new file mode 100644 index 00000000..98caa1ba --- /dev/null +++ b/src/services/doctor.ts @@ -0,0 +1,257 @@ +import { + Client, + PolymorfaAuthenticationError, + PolymorfaAuthorizationError, + SystemClient, +} from '@polymorfa/sdk' + +import type {CredentialStore} from '../config/credentials.js' +import type {ProfileConfig} from '../config/profiles.js' +import type {ProfileRepositoryLike} from './auth.js' + +export type ProjectLookup = (input: { + readonly baseUrl: string + readonly credential: string + readonly timeoutMs?: number +}) => Promise + +export type CheckStatus = 'ok' | 'warn' | 'fail' | 'skip' + +export interface DoctorCheck { + readonly detail: string + readonly name: 'node' | 'config' | 'keyring' | 'api' | 'credential' + readonly status: CheckStatus +} + +export interface DoctorReport { + readonly checks: readonly DoctorCheck[] + readonly ok: boolean +} + +export interface DoctorDependencies { + readonly credentials: CredentialStore + readonly env: Readonly> + readonly lookup?: ProjectLookup + readonly nodeVersion?: string + readonly ping?: (baseUrl: string) => Promise + readonly profiles: ProfileRepositoryLike & {readonly path?: string} + readonly profile?: string | undefined + readonly timeoutMs?: number | undefined +} + +const MIN_NODE_MAJOR = 22 +const DEFAULT_BASE_URL = 'https://api.polymorfa.com' + +/** Local and remote health checks. Never prints or returns credential values. */ +export async function runDoctor( + deps: DoctorDependencies, +): Promise { + const checks: DoctorCheck[] = [ + nodeCheck(deps.nodeVersion ?? process.versions.node), + ] + + let config: ProfileConfig | undefined + try { + config = await deps.profiles.load() + const count = Object.keys(config.profiles).length + checks.push({ + name: 'config', + status: 'ok', + detail: `${count} profile(s)${config.activeProfile === undefined ? ', no active profile' : `, active ${config.activeProfile}`}.`, + }) + } catch (error) { + checks.push({name: 'config', status: 'fail', detail: message(error)}) + } + + const envKey = nonEmpty(deps.env.POLYMORFA_API_KEY) + const envOther = + nonEmpty(deps.env.POLYMORFA_PROJECT_TOKEN) ?? + nonEmpty(deps.env.POLYMORFA_CLIENT_TOKEN) + const useEnv = + nonEmpty(deps.profile) === undefined && + (envKey !== undefined || envOther !== undefined) + const profileName = nonEmpty(deps.profile) ?? config?.activeProfile + const profile = + profileName === undefined ? undefined : config?.profiles[profileName] + + // Keyring: read the selected profile's entry, or probe an unused account. + let credential: string | undefined + let kind: 'apiKey' | 'projectToken' | 'clientToken' | undefined + try { + if (profile !== undefined) { + const stored = await deps.credentials.get(profile.credential.account) + checks.push({ + name: 'keyring', + status: stored === undefined ? 'fail' : 'ok', + detail: + stored === undefined + ? `Keyring is reachable but has no credential for profile ${profileName}. Run polymorfa auth login --profile ${profileName}.` + : `Keyring is reachable; profile ${profileName} credential is present.`, + }) + if (!useEnv) { + credential = stored + kind = profile.credential.kind + } + } else { + await deps.credentials.get('doctor:probe') + checks.push({ + name: 'keyring', + status: profileName === undefined ? 'ok' : 'fail', + detail: + profileName === undefined + ? 'Keyring is reachable; no profile is selected.' + : `Profile ${profileName} does not exist.`, + }) + } + } catch (error) { + checks.push({name: 'keyring', status: 'fail', detail: message(error)}) + } + if (useEnv) { + credential = envKey ?? envOther + kind = + envKey !== undefined + ? 'apiKey' + : nonEmpty(deps.env.POLYMORFA_PROJECT_TOKEN) !== undefined + ? 'projectToken' + : 'clientToken' + } + + const baseUrl = + nonEmpty(deps.env.POLYMORFA_BASE_URL) ?? + (useEnv ? undefined : profile?.baseUrl) ?? + DEFAULT_BASE_URL + let reachable = false + try { + await (deps.ping ?? defaultPing(deps.timeoutMs))(baseUrl) + reachable = true + checks.push({ + name: 'api', + status: 'ok', + detail: `${baseUrl}/ping responded.`, + }) + } catch (error) { + checks.push({ + name: 'api', + status: 'fail', + detail: `${baseUrl} is unreachable: ${message(error)}`, + }) + } + + checks.push( + await credentialCheck({ + baseUrl, + credential, + kind, + lookup: deps.lookup ?? listProjectsPing, + reachable, + source: useEnv ? 'environment' : `profile ${profileName ?? '-'}`, + timeoutMs: deps.timeoutMs, + }), + ) + + return {checks, ok: checks.every((check) => check.status !== 'fail')} +} + +function nodeCheck(version: string): DoctorCheck { + const major = Number(version.split('.')[0]) + return major >= MIN_NODE_MAJOR + ? {name: 'node', status: 'ok', detail: `Node.js ${version}.`} + : { + name: 'node', + status: 'fail', + detail: `Node.js ${version} is below the required ${MIN_NODE_MAJOR}.`, + } +} + +async function credentialCheck(input: { + baseUrl: string + credential: string | undefined + kind: 'apiKey' | 'projectToken' | 'clientToken' | undefined + lookup: ProjectLookup + reachable: boolean + source: string + timeoutMs: number | undefined +}): Promise { + if (input.credential === undefined || input.kind === undefined) { + return { + name: 'credential', + status: 'warn', + detail: + 'No credential is configured. Set POLYMORFA_API_KEY or run polymorfa auth login.', + } + } + if (!input.reachable) { + return { + name: 'credential', + status: 'skip', + detail: 'Skipped: the API is unreachable.', + } + } + if (input.kind !== 'apiKey') { + return { + name: 'credential', + status: 'skip', + detail: `A ${input.kind === 'projectToken' ? 'project token' : 'client token'} from ${input.source} is present; doctor validates only server API keys remotely.`, + } + } + try { + await input.lookup({ + baseUrl: input.baseUrl, + credential: input.credential, + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + }) + return { + name: 'credential', + status: 'ok', + detail: `API key from ${input.source} authenticated (project list).`, + } + } catch (error) { + if (error instanceof PolymorfaAuthorizationError) { + return { + name: 'credential', + status: 'warn', + detail: `API key from ${input.source} authenticated but lacks sessions:read, so the project list was denied.`, + } + } + return { + name: 'credential', + status: 'fail', + detail: + error instanceof PolymorfaAuthenticationError + ? `API key from ${input.source} was rejected (401). Create a new key and log in again.` + : `Could not validate the API key from ${input.source}: ${message(error)}`, + } + } +} + +async function listProjectsPing(input: { + readonly baseUrl: string + readonly credential: string + readonly timeoutMs?: number +}): Promise { + return new Client({ + baseUrl: input.baseUrl, + credential: {type: 'organizationApiKey', value: input.credential}, + maxNetworkRetries: 0, + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + }).projects.list() +} + +function defaultPing(timeoutMs: number | undefined) { + return async (baseUrl: string): Promise => { + await new SystemClient({ + baseUrl, + maxNetworkRetries: 0, + ...(timeoutMs === undefined ? {timeoutMs: 10_000} : {timeoutMs}), + }).ping() + } +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim() + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed +} diff --git a/test/services/doctor.test.ts b/test/services/doctor.test.ts new file mode 100644 index 00000000..8dc6401f --- /dev/null +++ b/test/services/doctor.test.ts @@ -0,0 +1,156 @@ +import { + PolymorfaAuthenticationError, + PolymorfaAuthorizationError, + PolymorfaConfigurationError, +} from '@polymorfa/sdk' +import {describe, expect, test, vi} from 'vitest' + +import type {CredentialStore} from '../../src/config/credentials.js' +import type {ProfileConfig} from '../../src/config/profiles.js' +import {runDoctor, type DoctorDependencies} from '../../src/services/doctor.js' + +const KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const config: ProfileConfig = { + activeProfile: 'work', + profiles: { + work: {credential: {account: 'profile:work:apiKey', kind: 'apiKey'}}, + }, + version: 2, +} + +function deps(overrides: Partial = {}): DoctorDependencies { + const credentials: CredentialStore = { + delete: () => Promise.resolve(), + get: (account) => + Promise.resolve(account === 'profile:work:apiKey' ? KEY : undefined), + set: () => Promise.resolve(), + } + return { + credentials, + env: {}, + lookup: () => Promise.resolve([]), + nodeVersion: '22.4.0', + ping: () => Promise.resolve(), + profiles: { + load: () => Promise.resolve(config), + save: () => Promise.resolve(), + }, + ...overrides, + } +} + +const status = (report: Awaited>) => + Object.fromEntries(report.checks.map((check) => [check.name, check.status])) + +describe('doctor', () => { + test('passes every check with a valid profile key', async () => { + const lookup = vi.fn(() => Promise.resolve([])) + const report = await runDoctor(deps({lookup})) + + expect(report.ok).toBe(true) + expect(status(report)).toEqual({ + api: 'ok', + config: 'ok', + credential: 'ok', + keyring: 'ok', + node: 'ok', + }) + expect(lookup).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: 'https://api.polymorfa.com', + credential: KEY, + }), + ) + expect(JSON.stringify(report)).not.toContain(KEY) + }) + + test('fails an old Node.js version', async () => { + const report = await runDoctor(deps({nodeVersion: '20.11.0'})) + expect(report.ok).toBe(false) + expect(status(report).node).toBe('fail') + }) + + test('reports an inaccessible keyring without crashing', async () => { + const report = await runDoctor( + deps({ + credentials: { + delete: () => Promise.resolve(), + get: () => + Promise.reject( + new PolymorfaConfigurationError('Keyring locked.', 'keyring'), + ), + set: () => Promise.resolve(), + }, + }), + ) + expect(status(report)).toMatchObject({credential: 'warn', keyring: 'fail'}) + expect(report.ok).toBe(false) + }) + + test('reports invalid config', async () => { + const report = await runDoctor( + deps({ + profiles: { + load: () => + Promise.reject( + new PolymorfaConfigurationError('bad shape', 'config'), + ), + save: () => Promise.resolve(), + }, + }), + ) + expect(status(report).config).toBe('fail') + }) + + test('skips the key check when the API is unreachable', async () => { + const lookup = vi.fn() + const report = await runDoctor( + deps({lookup, ping: () => Promise.reject(new Error('ECONNREFUSED'))}), + ) + expect(status(report)).toMatchObject({api: 'fail', credential: 'skip'}) + expect(lookup).not.toHaveBeenCalled() + }) + + test('distinguishes a rejected key from a key missing scope', async () => { + const rejected = await runDoctor( + deps({ + lookup: () => Promise.reject(new PolymorfaAuthenticationError('no')), + }), + ) + const scoped = await runDoctor( + deps({ + lookup: () => Promise.reject(new PolymorfaAuthorizationError('no')), + }), + ) + expect(status(rejected).credential).toBe('fail') + expect(status(scoped).credential).toBe('warn') + expect(scoped.ok).toBe(true) + }) + + test('uses an environment key and base URL over the profile', async () => { + const lookup = vi.fn(() => Promise.resolve([])) + const ping = vi.fn(() => Promise.resolve()) + await runDoctor( + deps({ + env: { + POLYMORFA_API_KEY: 'pmfa_env', + POLYMORFA_BASE_URL: 'http://127.0.0.1:9', + }, + lookup, + ping, + }), + ) + expect(ping).toHaveBeenCalledWith('http://127.0.0.1:9') + expect(lookup).toHaveBeenCalledWith( + expect.objectContaining({credential: 'pmfa_env'}), + ) + }) + + test('does not remotely validate client tokens', async () => { + const report = await runDoctor( + deps({env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_x'}}), + ) + expect(status(report).credential).toBe('skip') + }) +}) From 6bbb054a4414e47160ef32a31d63d71728552eae Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 20:52:19 +0200 Subject: [PATCH 2/4] fix(cli): doctor keyring warn under env credentials; neutral 403 wording --- README.md | 14 +++++++------- src/services/doctor.ts | 5 +++-- test/services/doctor.test.ts | 15 +++++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 2c2d0a93..29d15574 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,13 @@ polymorfa doctor --profile work --json `doctor` runs five checks and exits with code 1 if any fails: -| Check | What it does | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `node` | Requires Node.js 22 or newer. | -| `config` | Loads and validates the profile config and reports the active profile. | -| `keyring` | Reads the selected profile's entry from the operating-system keyring. A locked or unavailable keyring fails. | -| `api` | Calls the unauthenticated `/ping` on the configured base URL, without retries. | -| `credential` | For a server API key, lists projects as an authenticated ping. `401` fails; `403` warns that the key is valid but lacks `sessions:read`. Project and client tokens are reported as present but are not validated remotely. | +| Check | What it does | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `node` | Requires Node.js 22 or newer. | +| `config` | Loads and validates the profile config and reports the active profile. | +| `keyring` | Reads the selected profile's entry from the operating-system keyring. A locked or unavailable keyring fails. | +| `api` | Calls the unauthenticated `/ping` on the configured base URL, without retries. | +| `credential` | For a server API key, lists projects as an authenticated ping. `401` fails; `403` warns that the key authenticated but project listing was denied. Project and client tokens are reported as present but are not validated remotely. | Environment credentials take precedence over the active profile, as for other commands. Output never contains credential values. diff --git a/src/services/doctor.ts b/src/services/doctor.ts index 98caa1ba..b8298a6d 100644 --- a/src/services/doctor.ts +++ b/src/services/doctor.ts @@ -82,7 +82,8 @@ export async function runDoctor( const stored = await deps.credentials.get(profile.credential.account) checks.push({ name: 'keyring', - status: stored === undefined ? 'fail' : 'ok', + // An unused profile cannot fail the run when env credentials apply. + status: stored !== undefined ? 'ok' : useEnv ? 'warn' : 'fail', detail: stored === undefined ? `Keyring is reachable but has no credential for profile ${profileName}. Run polymorfa auth login --profile ${profileName}.` @@ -210,7 +211,7 @@ async function credentialCheck(input: { return { name: 'credential', status: 'warn', - detail: `API key from ${input.source} authenticated but lacks sessions:read, so the project list was denied.`, + detail: `API key from ${input.source} authenticated, but listing projects was denied (403); the key may lack the scope for that request.`, } } return { diff --git a/test/services/doctor.test.ts b/test/services/doctor.test.ts index 8dc6401f..5a621b22 100644 --- a/test/services/doctor.test.ts +++ b/test/services/doctor.test.ts @@ -147,6 +147,21 @@ describe('doctor', () => { ) }) + test('only warns about an empty profile entry when env credentials apply', async () => { + const report = await runDoctor( + deps({ + credentials: { + delete: () => Promise.resolve(), + get: () => Promise.resolve(undefined), + set: () => Promise.resolve(), + }, + env: {POLYMORFA_API_KEY: 'pmfa_env'}, + }), + ) + expect(status(report)).toMatchObject({credential: 'ok', keyring: 'warn'}) + expect(report.ok).toBe(true) + }) + test('does not remotely validate client tokens', async () => { const report = await runDoctor( deps({env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_x'}}), From 154a1f1fadc4ee9e994546d27e870d13b20ff39c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 09:04:46 +0200 Subject: [PATCH 3/4] fix(cli): do not fail doctor for a stale active profile under env credentials The missing-profile branch hardcoded fail, so a stale activeProfile made doctor exit non-zero even when the environment credential was the one being validated. Downgrade to warn, matching the sibling branch. --- src/services/doctor.ts | 8 ++++++-- test/services/doctor.test.ts | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/services/doctor.ts b/src/services/doctor.ts index b8298a6d..001ca175 100644 --- a/src/services/doctor.ts +++ b/src/services/doctor.ts @@ -97,11 +97,15 @@ export async function runDoctor( await deps.credentials.get('doctor:probe') checks.push({ name: 'keyring', - status: profileName === undefined ? 'ok' : 'fail', + // A stale active profile cannot fail the run when env credentials + // apply, matching the branch above. + status: profileName === undefined ? 'ok' : useEnv ? 'warn' : 'fail', detail: profileName === undefined ? 'Keyring is reachable; no profile is selected.' - : `Profile ${profileName} does not exist.`, + : useEnv + ? `Profile ${profileName} does not exist; the environment credential is in use.` + : `Profile ${profileName} does not exist.`, }) } } catch (error) { diff --git a/test/services/doctor.test.ts b/test/services/doctor.test.ts index 5a621b22..5f9fd8e8 100644 --- a/test/services/doctor.test.ts +++ b/test/services/doctor.test.ts @@ -162,6 +162,21 @@ describe('doctor', () => { expect(report.ok).toBe(true) }) + test('only warns about a missing active profile when env credentials apply', async () => { + const report = await runDoctor( + deps({ + env: {POLYMORFA_API_KEY: 'pmfa_env'}, + profiles: { + load: () => + Promise.resolve({activeProfile: 'gone', profiles: {}, version: 2}), + save: () => Promise.resolve(), + }, + }), + ) + expect(status(report).keyring).toBe('warn') + expect(report.ok).toBe(true) + }) + test('does not remotely validate client tokens', async () => { const report = await runDoctor( deps({env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_x'}}), From cd18911b98319e3c168ce6b5c006ca81a1fa987e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:18:53 +0200 Subject: [PATCH 4/4] fix(cli): do not fail doctor for an unreachable keyring under env credentials The catch around the keyring block hardcoded fail, bypassing the env downgrade the branches above apply, so doctor exited 1 in CI containers without a secret service even though commands resolve the environment credential first. Also point the remediation at a runnable auth login invocation; it requires a credential-source flag. --- README.md | 2 +- src/services/doctor.ts | 14 +++++++++++--- test/services/doctor.test.ts | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 29d15574..d06d1bcf 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ polymorfa doctor --profile work --json | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `node` | Requires Node.js 22 or newer. | | `config` | Loads and validates the profile config and reports the active profile. | -| `keyring` | Reads the selected profile's entry from the operating-system keyring. A locked or unavailable keyring fails. | +| `keyring` | Reads the selected profile's entry from the operating-system keyring. A locked or unavailable keyring fails, or warns when an environment credential is in use. | | `api` | Calls the unauthenticated `/ping` on the configured base URL, without retries. | | `credential` | For a server API key, lists projects as an authenticated ping. `401` fails; `403` warns that the key authenticated but project listing was denied. Project and client tokens are reported as present but are not validated remotely. | diff --git a/src/services/doctor.ts b/src/services/doctor.ts index 001ca175..649dec6a 100644 --- a/src/services/doctor.ts +++ b/src/services/doctor.ts @@ -86,7 +86,7 @@ export async function runDoctor( status: stored !== undefined ? 'ok' : useEnv ? 'warn' : 'fail', detail: stored === undefined - ? `Keyring is reachable but has no credential for profile ${profileName}. Run polymorfa auth login --profile ${profileName}.` + ? `Keyring is reachable but has no credential for profile ${profileName}. Run polymorfa auth login --with-api-key --profile ${profileName}.` : `Keyring is reachable; profile ${profileName} credential is present.`, }) if (!useEnv) { @@ -109,7 +109,15 @@ export async function runDoctor( }) } } catch (error) { - checks.push({name: 'keyring', status: 'fail', detail: message(error)}) + // An unreachable keyring cannot fail the run when env credentials apply; + // commands resolve those before ever touching it. + checks.push({ + name: 'keyring', + status: useEnv ? 'warn' : 'fail', + detail: useEnv + ? `${message(error)} The environment credential is in use, so commands are unaffected.` + : message(error), + }) } if (useEnv) { credential = envKey ?? envOther @@ -182,7 +190,7 @@ async function credentialCheck(input: { name: 'credential', status: 'warn', detail: - 'No credential is configured. Set POLYMORFA_API_KEY or run polymorfa auth login.', + 'No credential is configured. Set POLYMORFA_API_KEY or run polymorfa auth login --with-api-key.', } } if (!input.reachable) { diff --git a/test/services/doctor.test.ts b/test/services/doctor.test.ts index 5f9fd8e8..f389bcd1 100644 --- a/test/services/doctor.test.ts +++ b/test/services/doctor.test.ts @@ -177,6 +177,24 @@ describe('doctor', () => { expect(report.ok).toBe(true) }) + test('only warns about an unreachable keyring when env credentials apply', async () => { + const report = await runDoctor( + deps({ + credentials: { + delete: () => Promise.resolve(), + get: () => + Promise.reject( + new PolymorfaConfigurationError('Keyring locked.', 'keyring'), + ), + set: () => Promise.resolve(), + }, + env: {POLYMORFA_API_KEY: 'pmfa_env'}, + }), + ) + expect(status(report)).toMatchObject({credential: 'ok', keyring: 'warn'}) + expect(report.ok).toBe(true) + }) + test('does not remotely validate client tokens', async () => { const report = await runDoctor( deps({env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_x'}}),