From f371e5347edd89039a719ce53339de3005fc0dd2 Mon Sep 17 00:00:00 2001 From: Andy00L <89641810+Andy00L@users.noreply.github.com> Date: Sun, 5 Jul 2026 19:54:01 -0400 Subject: [PATCH 1/2] feat(cli): add "testsprite doctor" environment diagnostic One-shot preflight: checks CLI version, Node runtime, profile, API endpoint, credentials, live connectivity (GET /me), and verify-skill install. Prints an OK/WARN/FAIL report and exits non-zero when any check fails, so it gates a CI step or agent preflight. Reuses the real resolution helpers; the API key is never printed. Fixes #73 --- src/commands/doctor.test.ts | 225 ++++++++++++++ src/commands/doctor.ts | 277 ++++++++++++++++++ src/index.ts | 2 + test/__snapshots__/help.snapshot.test.ts.snap | 2 + 4 files changed, 506 insertions(+) create mode 100644 src/commands/doctor.test.ts create mode 100644 src/commands/doctor.ts diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts new file mode 100644 index 0000000..eb69d0a --- /dev/null +++ b/src/commands/doctor.test.ts @@ -0,0 +1,225 @@ +/** + * Unit tests for `testsprite doctor`. + * + * The command reuses the real resolution helpers (loadConfig, makeHttpClient, + * isVerifySkillInstalled), so these tests inject env/credentials/fetch/fs and + * assert on the rendered report + the exit-on-failure contract. + */ + +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CLIError } from '../lib/errors.js'; +import { writeProfile } from '../lib/credentials.js'; +import type { DoctorDeps, DoctorReport } from './doctor.js'; +import { createDoctorCommand, runDoctor } from './doctor.js'; + +interface CapturedOutput { + stdout: string[]; + stderr: string[]; +} + +function makeCapture(): { capture: CapturedOutput; deps: Pick } { + const capture: CapturedOutput = { stdout: [], stderr: [] }; + return { + capture, + deps: { + stdout: line => capture.stdout.push(line), + stderr: line => capture.stderr.push(line), + }, + }; +} + +function makeFetch(body: unknown, status = 200): DoctorDeps['fetchImpl'] { + return vi.fn( + async () => + new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }), + ) as unknown as DoctorDeps['fetchImpl']; +} + +const OK_ME = { userId: 'u-doc', keyId: 'k-doc' }; + +/** Base deps shared by the healthy-path tests: node OK, skill installed, empty env. */ +function healthyDeps(credentialsPath: string, extra: Partial = {}): DoctorDeps { + return { + env: {}, + credentialsPath, + cwd: '/project', + nodeVersion: '22.9.0', + existsSync: () => true, // skill landing file present + fetchImpl: makeFetch(OK_ME), + ...extra, + }; +} + +let credentialsPath: string; + +beforeEach(() => { + credentialsPath = join(mkdtempSync(join(tmpdir(), 'testsprite-doctor-')), 'credentials'); +}); + +describe('runDoctor — healthy environment', () => { + it('returns an all-passing report and does not throw', async () => { + writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + const report = await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath), ...deps }, + ); + expect(report.failures).toBe(0); + expect(report.warnings).toBe(0); + const out = capture.stdout.join('\n'); + expect(out).toContain('[OK]'); + expect(out).toContain('All checks passed.'); + expect(out).toContain('reached GET /me'); + }); + + it('never prints the API key anywhere in the report', async () => { + writeProfile('default', { apiKey: 'sk-super-secret-value' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath), ...deps }, + ); + const all = capture.stdout.join('\n') + capture.stderr.join('\n'); + expect(all).not.toContain('sk-super-secret-value'); + }); + + it('emits a machine-readable report under --output json', async () => { + writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + await runDoctor( + { profile: 'default', output: 'json', debug: false }, + { ...healthyDeps(credentialsPath), ...deps }, + ); + const parsed = JSON.parse(capture.stdout.join('')) as DoctorReport; + expect(parsed.failures).toBe(0); + expect(Array.isArray(parsed.checks)).toBe(true); + expect( + parsed.checks.some(check => check.name === 'Connectivity' && check.status === 'ok'), + ).toBe(true); + }); +}); + +describe('runDoctor — failing checks exit non-zero', () => { + it('missing API key fails Credentials and throws CLIError (exit 1)', async () => { + const { capture, deps } = makeCapture(); + const rejection = await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath), ...deps }, // no profile written => no key + ).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CLIError); + expect(rejection).toMatchObject({ exitCode: 1 }); + const out = capture.stdout.join('\n'); + expect(out).toContain('[FAIL]'); + expect(out).toContain('Credentials'); + }); + + it('invalid endpoint URL fails the API endpoint check', async () => { + writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + const rejection = await runDoctor( + { profile: 'default', output: 'text', debug: false, endpointUrl: 'not-a-url' }, + { ...healthyDeps(credentialsPath), ...deps }, + ).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CLIError); + const out = capture.stdout.join('\n'); + expect(out).toContain('API endpoint'); + expect(out).toContain('not a valid'); + }); + + it('rejected API key surfaces as a Connectivity failure', async () => { + writeProfile('default', { apiKey: 'sk-bad' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + const authError = { + error: { code: 'AUTH_INVALID', message: 'Bad key.', requestId: 'req_x', details: {} }, + }; + const rejection = await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath, { fetchImpl: makeFetch(authError, 401) }), ...deps }, + ).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CLIError); + const out = capture.stdout.join('\n'); + expect(out).toContain('Connectivity'); + expect(out).toContain('API key rejected (AUTH_INVALID)'); + }); + + it('a non-auth /me error is reported as a Connectivity failure with its code', async () => { + writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + const notFound = { + error: { code: 'NOT_FOUND', message: 'nope', requestId: 'req_y', details: {} }, + }; + const rejection = await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath, { fetchImpl: makeFetch(notFound, 404) }), ...deps }, + ).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CLIError); + expect(capture.stdout.join('\n')).toContain('GET /me failed (NOT_FOUND)'); + }); + + it('an outdated Node runtime fails the Node.js check', async () => { + writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + const rejection = await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath, { nodeVersion: '18.0.0' }), ...deps }, + ).catch((error: unknown) => error); + expect(rejection).toBeInstanceOf(CLIError); + const out = capture.stdout.join('\n'); + expect(out).toContain('Node.js'); + expect(out).toContain('below the required Node 20'); + }); +}); + +describe('runDoctor — warnings do not fail', () => { + it('missing verify skill is a warning, not a failure', async () => { + writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + const report = await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath, { existsSync: () => false }), ...deps }, + ); + expect(report.failures).toBe(0); + expect(report.warnings).toBeGreaterThanOrEqual(1); + const out = capture.stdout.join('\n'); + expect(out).toContain('[WARN]'); + expect(out).toContain('Verify skill'); + }); + + it('--dry-run skips connectivity and never calls fetch, missing key is a warning', async () => { + const fetchImpl = vi.fn(async () => { + throw new Error('fetch must not be called under --dry-run'); + }) as unknown as DoctorDeps['fetchImpl']; + const { capture, deps } = makeCapture(); + const report = await runDoctor( + { profile: 'default', output: 'text', debug: false, dryRun: true }, + { + env: {}, + credentialsPath, + cwd: '/project', + nodeVersion: '22.9.0', + existsSync: () => true, + fetchImpl, + ...deps, + }, + ); + expect(report.failures).toBe(0); + expect(fetchImpl).not.toHaveBeenCalled(); + expect(capture.stdout.join('\n')).toContain('skipped under --dry-run'); + }); +}); + +describe('createDoctorCommand wiring', () => { + it('exposes the doctor command name', () => { + expect(createDoctorCommand().name()).toBe('doctor'); + }); + + it('--help describes the diagnostic', () => { + expect(createDoctorCommand().helpInformation()).toContain('Diagnose'); + }); +}); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts new file mode 100644 index 0000000..31598a1 --- /dev/null +++ b/src/commands/doctor.ts @@ -0,0 +1,277 @@ +/** + * `testsprite doctor` — one-shot environment diagnostic. + * + * Runs a fixed checklist (CLI version, Node.js runtime, active profile, API + * endpoint, credentials, live connectivity + key validity, and whether the + * verify skill is installed in the current project) and prints an OK/WARN/FAIL + * report. Exits non-zero when any check FAILS so it can gate a CI step or an + * agent preflight (`testsprite doctor && testsprite test run ...`). Warnings + * (e.g. skill not installed) do not fail the process. + * + * Every check is reused from the same helpers the real commands use, so the + * report reflects exactly what a subsequent command would resolve: `loadConfig` + * for profile/endpoint/key, `assertValidEndpointUrl` for the endpoint gate, + * `makeHttpClient` + `GET /me` for connectivity, and `isVerifySkillInstalled` + * for the skill check. + */ + +import { Command } from 'commander'; +import { + assertValidEndpointUrl, + makeHttpClient, + type CommonOptions as FactoryCommonOptions, +} from '../lib/client-factory.js'; +import { loadConfig } from '../lib/config.js'; +import { ApiError, CLIError } from '../lib/errors.js'; +import type { FetchImpl } from '../lib/http.js'; +import { GLOBAL_OPTS_HINT, Output, type OutputMode } from '../lib/output.js'; +import { isVerifySkillInstalled } from '../lib/skill-nudge.js'; +import { VERSION } from '../version.js'; + +/** Minimum Node major version. sourceRef: package.json engines.node ">=20". */ +const MIN_NODE_MAJOR = 20; + +export type DoctorStatus = 'ok' | 'warn' | 'fail'; + +export interface DoctorCheck { + /** Short, stable label (also the JSON key-ish name). */ + name: string; + status: DoctorStatus; + /** Human-readable one-line result. Never contains the API key. */ + detail: string; +} + +export interface DoctorReport { + checks: DoctorCheck[]; + failures: number; + warnings: number; +} + +/** Minimal projection of `GET /me` we read for the connectivity detail. */ +interface MeIdentity { + userId?: string; + keyId?: string; +} + +export interface DoctorDeps { + env?: NodeJS.ProcessEnv; + credentialsPath?: string; + fetchImpl?: FetchImpl; + stdout?: (line: string) => void; + stderr?: (line: string) => void; + /** Project dir for the skill check. Defaults to `process.cwd()`. */ + cwd?: string; + /** Runtime version string (e.g. "22.9.0"). Defaults to `process.versions.node`. */ + nodeVersion?: string; + existsSync?: (p: string) => boolean; + readFileSync?: (p: string) => string; +} + +type CommonOptions = FactoryCommonOptions; + +export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Promise { + const out = makeOutput(opts.output, deps); + const env = deps.env ?? process.env; + const cwd = deps.cwd ?? process.cwd(); + const nodeVersion = deps.nodeVersion ?? process.versions.node; + + const config = loadConfig({ + profile: opts.profile, + endpointUrl: opts.endpointUrl, + env, + credentialsPath: deps.credentialsPath, + }); + const endpointCheck = checkEndpoint(config.apiUrl); + const hasKey = Boolean(config.apiKey); + + const checks: DoctorCheck[] = [ + { name: 'CLI version', status: 'ok', detail: VERSION }, + checkNodeVersion(nodeVersion), + { name: 'Profile', status: 'ok', detail: config.profile }, + endpointCheck, + checkCredentials(hasKey, config.profile, opts.dryRun ?? false), + await checkConnectivity(opts, deps, { + hasKey, + endpointOk: endpointCheck.status === 'ok', + }), + checkSkill(cwd, deps), + ]; + + const failures = checks.filter(check => check.status === 'fail').length; + const warnings = checks.filter(check => check.status === 'warn').length; + const report: DoctorReport = { checks, failures, warnings }; + + out.print(report, () => renderDoctor(report)); + + if (failures > 0) { + // Non-zero exit so `testsprite doctor && ...` gates a CI step or an agent + // preflight. The full report already printed above; this line is the stderr + // summary index.ts renders before exiting 1. + throw new CLIError(`doctor: ${failures} check(s) failed, ${warnings} warning(s)`, 1); + } + return report; +} + +function checkNodeVersion(nodeVersion: string): DoctorCheck { + const major = parseInt(nodeVersion.split('.')[0] ?? '', 10); + const ok = Number.isInteger(major) && major >= MIN_NODE_MAJOR; + return { + name: 'Node.js', + status: ok ? 'ok' : 'fail', + detail: ok + ? `v${nodeVersion} (>=${MIN_NODE_MAJOR} required)` + : `v${nodeVersion} is below the required Node ${MIN_NODE_MAJOR}; upgrade Node.js`, + }; +} + +function checkEndpoint(apiUrl: string): DoctorCheck { + try { + assertValidEndpointUrl(apiUrl); + return { name: 'API endpoint', status: 'ok', detail: apiUrl }; + } catch { + return { + name: 'API endpoint', + status: 'fail', + detail: `"${apiUrl}" is not a valid http(s) URL`, + }; + } +} + +function checkCredentials(hasKey: boolean, profile: string, dryRun: boolean): DoctorCheck { + if (hasKey) { + // Never print any part of the key (security). Confirm presence only. + return { + name: 'Credentials', + status: 'ok', + detail: `API key configured (profile "${profile}")`, + }; + } + // Under --dry-run no key is expected, so a missing key is not a failure. + return { + name: 'Credentials', + status: dryRun ? 'warn' : 'fail', + detail: dryRun + ? 'no API key (not needed under --dry-run)' + : 'no API key found; run `testsprite setup` (or set TESTSPRITE_API_KEY)', + }; +} + +function checkSkill(cwd: string, deps: DoctorDeps): DoctorCheck { + const installed = isVerifySkillInstalled(cwd, { + existsSync: deps.existsSync, + readFileSync: deps.readFileSync, + }); + return { + name: 'Verify skill', + status: installed ? 'ok' : 'warn', + detail: installed + ? 'installed in this project' + : 'not installed here; run `testsprite setup` so your agent verifies its changes', + }; +} + +async function checkConnectivity( + opts: CommonOptions, + deps: DoctorDeps, + ctx: { hasKey: boolean; endpointOk: boolean }, +): Promise { + const name = 'Connectivity'; + if (opts.dryRun) return { name, status: 'warn', detail: 'skipped under --dry-run' }; + if (!ctx.hasKey) return { name, status: 'warn', detail: 'skipped; no API key to test with' }; + if (!ctx.endpointOk) return { name, status: 'warn', detail: 'skipped; endpoint URL is invalid' }; + + try { + const client = makeHttpClient(opts, { + env: deps.env, + credentialsPath: deps.credentialsPath, + fetchImpl: deps.fetchImpl, + stderr: deps.stderr, + }); + const me = await client.get('/me'); + const who = me.userId ? ` (userId ${me.userId})` : ''; + return { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }; + } catch (error) { + if (error instanceof ApiError) { + if ( + error.code === 'AUTH_REQUIRED' || + error.code === 'AUTH_INVALID' || + error.code === 'AUTH_FORBIDDEN' + ) { + return { name, status: 'fail', detail: `API key rejected (${error.code})` }; + } + return { name, status: 'fail', detail: `GET /me failed (${error.code})` }; + } + return { + name, + status: 'fail', + detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})`, + }; + } +} + +const STATUS_LABEL: Record = { + ok: '[OK] ', + warn: '[WARN]', + fail: '[FAIL]', +}; + +function renderDoctor(report: DoctorReport): string { + const nameWidth = Math.max(...report.checks.map(check => check.name.length)); + const lines: string[] = ['TestSprite doctor', '']; + for (const check of report.checks) { + lines.push(` ${STATUS_LABEL[check.status]} ${check.name.padEnd(nameWidth)} ${check.detail}`); + } + lines.push(''); + lines.push( + report.failures === 0 && report.warnings === 0 + ? 'All checks passed.' + : `${report.failures} failure(s), ${report.warnings} warning(s).`, + ); + return lines.join('\n'); +} + +export function createDoctorCommand(deps: DoctorDeps = {}): Command { + const cmd = new Command('doctor') + .description( + 'Diagnose CLI setup: version, Node, profile, endpoint, credentials, connectivity, skill', + ) + .addHelpText('after', GLOBAL_OPTS_HINT) + .addHelpText( + 'after', + '\nExamples:\n' + + ' testsprite doctor # run all checks (exit 1 if any fails)\n' + + ' testsprite doctor --output json # machine-readable report\n' + + ' testsprite doctor && testsprite test run # gate a command on a healthy setup', + ) + .action(async (_cmdOpts, command: Command) => { + await runDoctor(resolveCommonOptions(command), deps); + }); + + return cmd; +} + +function resolveCommonOptions(command: Command): CommonOptions { + const globals = command.optsWithGlobals() as Partial & { + requestTimeout?: string; + }; + return { + profile: globals.profile ?? 'default', + output: globals.output ?? 'text', + endpointUrl: globals.endpointUrl, + debug: globals.debug ?? false, + verbose: globals.verbose ?? false, + dryRun: globals.dryRun ?? false, + requestTimeoutMs: parseRequestTimeoutFlag(globals.requestTimeout), + }; +} + +function parseRequestTimeoutFlag(raw: string | undefined): number | undefined { + if (raw === undefined) return undefined; + const seconds = Number(raw); + if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + return Math.round(seconds * 1000); +} + +function makeOutput(mode: OutputMode, deps: DoctorDeps): Output { + return new Output(mode, { stdout: deps.stdout, stderr: deps.stderr }); +} diff --git a/src/index.ts b/src/index.ts index fb935c8..78010c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Command, CommanderError } from 'commander'; import { createAgentCommand } from './commands/agent.js'; import { createAuthCommand } from './commands/auth.js'; +import { createDoctorCommand } from './commands/doctor.js'; import { createDeprecatedInitCommand, createSetupCommand, @@ -89,6 +90,7 @@ program.addCommand(createProjectCommand({})); program.addCommand(createTestCommand()); program.addCommand(createAgentCommand({})); program.addCommand(createUsageCommand()); +program.addCommand(createDoctorCommand()); // Buffer Commander error messages instead of writing immediately. The catch // block re-emits in the correct format (JSON or text) once the requested diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 9fb6b2d..59907a7 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -654,6 +654,8 @@ Commands: Antigravity, Codex) usage|credits Show credit balance and plan/entitlement info (proactive pre-flight before a large test run) + doctor Diagnose CLI setup: version, Node, profile, + endpoint, credentials, connectivity, skill help [command] display help for command " `; From 3ba7438977abc7197710d8c1aa2c9342b231eb0e Mon Sep 17 00:00:00 2001 From: Andy00L <89641810+Andy00L@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:04:35 -0400 Subject: [PATCH 2/2] fix(doctor): address review findings - Node check reuses the CLI runtime guard (shouldRejectNodeVersion) instead of a hardcoded major floor, so the verdict matches what the entrypoint enforces at startup; the precise 20.19+/22.13+/24+ engines are enforced by npm engine-strict. - --request-timeout raises a validation error on malformed input instead of silently defaulting, matching the other commands. - The --output json test asserts the API key never appears in the JSON path (distinct from the text renderer already covered). --- src/commands/doctor.test.ts | 10 +++++++--- src/commands/doctor.ts | 30 +++++++++++++++++++----------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index eb69d0a..230398a 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -89,14 +89,18 @@ describe('runDoctor — healthy environment', () => { expect(all).not.toContain('sk-super-secret-value'); }); - it('emits a machine-readable report under --output json', async () => { - writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + it('emits a machine-readable report under --output json without leaking the API key', async () => { + writeProfile('default', { apiKey: 'sk-json-secret-value' }, { path: credentialsPath }); const { capture, deps } = makeCapture(); await runDoctor( { profile: 'default', output: 'json', debug: false }, { ...healthyDeps(credentialsPath), ...deps }, ); - const parsed = JSON.parse(capture.stdout.join('')) as DoctorReport; + const raw = capture.stdout.join(''); + // Security: the JSON serialization path is distinct from the text renderer, + // so assert the key never leaks here either. + expect(raw).not.toContain('sk-json-secret-value'); + const parsed = JSON.parse(raw) as DoctorReport; expect(parsed.failures).toBe(0); expect(Array.isArray(parsed.checks)).toBe(true); expect( diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 31598a1..2fa0c57 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -22,14 +22,12 @@ import { type CommonOptions as FactoryCommonOptions, } from '../lib/client-factory.js'; import { loadConfig } from '../lib/config.js'; -import { ApiError, CLIError } from '../lib/errors.js'; +import { ApiError, CLIError, localValidationError } from '../lib/errors.js'; import type { FetchImpl } from '../lib/http.js'; import { GLOBAL_OPTS_HINT, Output, type OutputMode } from '../lib/output.js'; import { isVerifySkillInstalled } from '../lib/skill-nudge.js'; import { VERSION } from '../version.js'; - -/** Minimum Node major version. sourceRef: package.json engines.node ">=20". */ -const MIN_NODE_MAJOR = 20; +import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js'; export type DoctorStatus = 'ok' | 'warn' | 'fail'; @@ -113,14 +111,17 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro } function checkNodeVersion(nodeVersion: string): DoctorCheck { - const major = parseInt(nodeVersion.split('.')[0] ?? '', 10); - const ok = Number.isInteger(major) && major >= MIN_NODE_MAJOR; + // Reuse the CLI's own runtime guard so the verdict matches exactly what the + // entrypoint enforces at startup, rather than a divergent hardcoded check. + // The precise engines floor (20.19+/22.13+/24+) is enforced by npm at install + // time via .npmrc engine-strict. sourceRef: src/version-guard.ts. + const rejected = shouldRejectNodeVersion(nodeVersion); return { name: 'Node.js', - status: ok ? 'ok' : 'fail', - detail: ok - ? `v${nodeVersion} (>=${MIN_NODE_MAJOR} required)` - : `v${nodeVersion} is below the required Node ${MIN_NODE_MAJOR}; upgrade Node.js`, + status: rejected ? 'fail' : 'ok', + detail: rejected + ? `v${nodeVersion} is below the required Node ${MIN_SUPPORTED_NODE_MAJOR}; upgrade Node.js` + : `v${nodeVersion} (>=${MIN_SUPPORTED_NODE_MAJOR} required)`, }; } @@ -268,7 +269,14 @@ function resolveCommonOptions(command: Command): CommonOptions { function parseRequestTimeoutFlag(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const seconds = Number(raw); - if (!Number.isFinite(seconds) || seconds <= 0) return undefined; + if (!Number.isFinite(seconds) || seconds <= 0) { + // Match the other commands: a malformed --request-timeout is a validation + // error, not a silently-ignored default. + throw localValidationError( + 'request-timeout', + `must be a positive number of seconds (got "${raw}")`, + ); + } return Math.round(seconds * 1000); }