From d1e697cd6a71d494a94e301e08c99c3de69dc9b4 Mon Sep 17 00:00:00 2001 From: AInoAKARI <208797459+AInoAKARI@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:23:01 +0900 Subject: [PATCH 01/10] fix Node runtime version guard --- src/index.ts | 4 ++-- src/version-guard.test.ts | 39 ++++++++++++++++++++++++++++----------- src/version-guard.ts | 37 ++++++++++++++++++++++++++++++------- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/index.ts b/src/index.ts index bc5bd9ac..a4922239 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,13 +35,13 @@ import { } from './lib/telemetry.js'; import { maybeNotifyUpdate } from './lib/update-check.js'; import { VERSION } from './version.js'; -import { shouldRejectNodeVersion } from './version-guard.js'; +import { SUPPORTED_NODE_RANGE, shouldRejectNodeVersion } from './version-guard.js'; // Guard: exit early with a clear message on unsupported Node.js versions, // rather than failing later with a cryptic ESM/runtime error. if (shouldRejectNodeVersion(process.versions.node)) { process.stderr.write( - `Error: testsprite requires Node.js >= 20 (found ${process.versions.node}).\nInstall the latest LTS from https://nodejs.org\n`, + `Error: testsprite requires Node.js ${SUPPORTED_NODE_RANGE} (found ${process.versions.node}).\nInstall a supported Node.js release from https://nodejs.org\n`, ); process.exit(1); } diff --git a/src/version-guard.test.ts b/src/version-guard.test.ts index 19f0eabe..6682ac40 100644 --- a/src/version-guard.test.ts +++ b/src/version-guard.test.ts @@ -1,10 +1,17 @@ +import { createRequire } from 'node:module'; import { describe, expect, it } from 'vitest'; import { MIN_SUPPORTED_NODE_MAJOR, + SUPPORTED_NODE_ENGINE, parseMajorVersion, shouldRejectNodeVersion, } from './version-guard.js'; +const require = createRequire(import.meta.url); +const pkg: { engines: { node: string } } = require('../package.json') as { + engines: { node: string }; +}; + // These tests exercise the REAL guard functions used by src/index.ts, // imported here rather than re-declared, so a regression in the source is // actually caught. @@ -22,22 +29,32 @@ describe('parseMajorVersion', () => { }); describe('shouldRejectNodeVersion', () => { - it('rejects majors below the supported floor', () => { + it('stays pinned to package.json engines.node', () => { + expect(SUPPORTED_NODE_ENGINE).toBe(pkg.engines.node); + }); + + it('rejects runtimes outside the declared engines range', () => { expect(shouldRejectNodeVersion('18.19.1')).toBe(true); - expect(shouldRejectNodeVersion('16.20.2')).toBe(true); - expect(shouldRejectNodeVersion('14.21.3')).toBe(true); + expect(shouldRejectNodeVersion('20.0.0')).toBe(true); + expect(shouldRejectNodeVersion('20.18.99')).toBe(true); + expect(shouldRejectNodeVersion('21.99.0')).toBe(true); + expect(shouldRejectNodeVersion('22.0.0')).toBe(true); + expect(shouldRejectNodeVersion('22.12.99')).toBe(true); + expect(shouldRejectNodeVersion('23.99.99')).toBe(true); }); - it('accepts the supported floor and above', () => { - expect(shouldRejectNodeVersion('20.0.0')).toBe(false); - expect(shouldRejectNodeVersion('20.11.0')).toBe(false); - expect(shouldRejectNodeVersion('21.0.0')).toBe(false); - expect(shouldRejectNodeVersion('22.1.0')).toBe(false); + it('accepts every supported Node window', () => { + expect(shouldRejectNodeVersion('20.19.0')).toBe(false); + expect(shouldRejectNodeVersion('20.99.0')).toBe(false); + expect(shouldRejectNodeVersion('22.13.0')).toBe(false); + expect(shouldRejectNodeVersion('22.99.0')).toBe(false); + expect(shouldRejectNodeVersion('24.0.0')).toBe(false); + expect(shouldRejectNodeVersion('25.0.0')).toBe(false); }); - it(`treats exactly ${MIN_SUPPORTED_NODE_MAJOR} as supported (boundary)`, () => { - expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR}.0.0`)).toBe(false); - expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR - 1}.9.9`)).toBe(true); + it(`keeps the major floor constant at ${MIN_SUPPORTED_NODE_MAJOR}`, () => { + expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR}.19.0`)).toBe(false); + expect(shouldRejectNodeVersion(`${MIN_SUPPORTED_NODE_MAJOR - 1}.99.99`)).toBe(true); }); it('does not reject an unparseable version (guard never blocks on garbage)', () => { diff --git a/src/version-guard.ts b/src/version-guard.ts index a7fe4644..7e592bea 100644 --- a/src/version-guard.ts +++ b/src/version-guard.ts @@ -10,8 +10,13 @@ * real implementation the entrypoint uses — not a copy. */ -/** Minimum Node.js major version supported by the CLI (matches package.json `engines.node`). */ +/** Canonical supported range, pinned to package.json by the unit test. */ +export const SUPPORTED_NODE_ENGINE = '^20.19.0 || ^22.13.0 || >=24'; +/** Human-readable form of the supported range for startup errors. */ +export const SUPPORTED_NODE_RANGE = '20.19+, 22.13+, or 24+'; export const MIN_SUPPORTED_NODE_MAJOR = 20; +const MIN_NODE_20_MINOR = 19; +const MIN_NODE_22_MINOR = 13; /** * Parse the leading major version number from a Node.js version string. @@ -24,18 +29,36 @@ export function parseMajorVersion(nodeVersion: string): number { return Number(nodeVersion.split('.')[0]); } +function parseMajorMinor(nodeVersion: string): { major: number; minor: number } | null { + const [majorRaw, minorRaw] = nodeVersion.split('.'); + const major = Number(majorRaw); + const minor = Number(minorRaw); + if (!Number.isInteger(major) || !Number.isInteger(minor) || major < 0 || minor < 0) { + return null; + } + return { major, minor }; +} + /** - * Decide whether the given Node.js version is too old to run the CLI. + * Decide whether the given Node.js version is outside the supported engine range. * - * A version is rejected only when its major number is a real value below - * {@link MIN_SUPPORTED_NODE_MAJOR}. An unparseable string yields `NaN`, which is + * Node 20 is supported from 20.19, Node 22 from 22.13, and Node 24+ is supported. + * Odd-numbered intermediate releases are rejected. An unparseable string is * treated as "do not reject" so the guard never blocks on a version string it * cannot understand (the runtime would surface any real incompatibility itself). * * @param nodeVersion - a `process.versions.node` style string (e.g. `"18.19.1"`). - * @returns `true` when the runtime is below the supported floor and should be rejected. + * @returns `true` when the runtime is unsupported and should be rejected. */ export function shouldRejectNodeVersion(nodeVersion: string): boolean { - const major = parseMajorVersion(nodeVersion); - return !Number.isNaN(major) && major < MIN_SUPPORTED_NODE_MAJOR; + const parsed = parseMajorMinor(nodeVersion); + if (parsed === null) return false; + + const { major, minor } = parsed; + if (major < 20) return true; + if (major === 20) return minor < MIN_NODE_20_MINOR; + if (major === 21) return true; + if (major === 22) return minor < MIN_NODE_22_MINOR; + if (major === 23) return true; + return false; } From 4c271802ea3dc03df9712c2768221790294a4c80 Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Wed, 12 Aug 2026 11:44:58 +0900 Subject: [PATCH 02/10] fix: preserve major-only Node guard behavior --- src/version-guard.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/version-guard.ts b/src/version-guard.ts index 7e592bea..716d5987 100644 --- a/src/version-guard.ts +++ b/src/version-guard.ts @@ -32,7 +32,8 @@ export function parseMajorVersion(nodeVersion: string): number { function parseMajorMinor(nodeVersion: string): { major: number; minor: number } | null { const [majorRaw, minorRaw] = nodeVersion.split('.'); const major = Number(majorRaw); - const minor = Number(minorRaw); + // Preserve the old guard's behavior for injectable major-only versions such as "18". + const minor = minorRaw === undefined ? 0 : Number(minorRaw); if (!Number.isInteger(major) || !Number.isInteger(minor) || major < 0 || minor < 0) { return null; } @@ -42,10 +43,10 @@ function parseMajorMinor(nodeVersion: string): { major: number; minor: number } /** * Decide whether the given Node.js version is outside the supported engine range. * - * Node 20 is supported from 20.19, Node 22 from 22.13, and Node 24+ is supported. - * Odd-numbered intermediate releases are rejected. An unparseable string is - * treated as "do not reject" so the guard never blocks on a version string it - * cannot understand (the runtime would surface any real incompatibility itself). + * Mirrors `^20.19.0 || ^22.13.0 || >=24`: Node 20 is supported from 20.19, + * Node 22 from 22.13, odd intermediate majors 21/23 are rejected, and 24+ is supported. + * An unparseable string is treated as "do not reject" so the guard never blocks on a + * version string it cannot understand (the runtime would surface any incompatibility). * * @param nodeVersion - a `process.versions.node` style string (e.g. `"18.19.1"`). * @returns `true` when the runtime is unsupported and should be rejected. @@ -55,7 +56,7 @@ export function shouldRejectNodeVersion(nodeVersion: string): boolean { if (parsed === null) return false; const { major, minor } = parsed; - if (major < 20) return true; + if (major < MIN_SUPPORTED_NODE_MAJOR) return true; if (major === 20) return minor < MIN_NODE_20_MINOR; if (major === 21) return true; if (major === 22) return minor < MIN_NODE_22_MINOR; From ac1a4e3f153460e1a5949bfd6f4cf97dcd3cf8c9 Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Wed, 12 Aug 2026 12:43:15 +0900 Subject: [PATCH 03/10] fix: align doctor Node range messaging --- src/commands/doctor.ts | 207 +++++++---------------------------------- 1 file changed, 32 insertions(+), 175 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d53520a4..37aaa3ac 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -30,15 +30,13 @@ import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '.. import { isVerifySkillInstalled } from '../lib/skill-nudge.js'; import { emitV3RoutingAdvisory, routingLabel } from '../lib/v3-advisory.js'; import { VERSION } from '../version.js'; -import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js'; +import { SUPPORTED_NODE_RANGE, shouldRejectNodeVersion } from '../version-guard.js'; 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; } @@ -48,14 +46,11 @@ export interface DoctorReport { warnings: number; } -/** Minimal projection of `GET /me` we read for the connectivity detail. */ interface MeIdentity { userId?: string; keyId?: string; v3Enabled?: boolean; - /** Account-wide membership list. Absent-safe (older backends omit it). */ organizations?: CliOrgSummary[]; - /** The calling key's own org binding — membership keys only. */ org?: CliOrgBinding; } @@ -65,9 +60,7 @@ export interface DoctorDeps { 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; @@ -81,20 +74,11 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro 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 config = loadConfig({ profile: opts.profile, endpointUrl: opts.endpointUrl, env, credentialsPath: deps.credentialsPath }); const endpointCheck = checkEndpoint(config.apiUrl); const hasKey = Boolean(config.apiKey); const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); - - const connectivity = await checkConnectivity(opts, deps, { - hasKey, - endpointOk: endpointCheck.status === 'ok', - }); + const connectivity = await checkConnectivity(opts, deps, { hasKey, endpointOk: endpointCheck.status === 'ok' }); const checks: DoctorCheck[] = [ { name: 'CLI version', status: 'ok', detail: VERSION }, @@ -105,68 +89,36 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro connectivity.check, ]; - // Informational routing line, only when the backend reported it (no new call). if (connectivity.v3Enabled !== undefined) { const label = routingLabel(connectivity.v3Enabled); - checks.push({ - name: 'Routing', - status: 'ok', - detail: - connectivity.v3Enabled === true - ? `${label} (V3 execution routing is ON)` - : `${label} (default routing)`, - }); + checks.push({ name: 'Routing', status: 'ok', detail: connectivity.v3Enabled === true ? `${label} (V3 execution routing is ON)` : `${label} (default routing)` }); } - // Org attribution lines — only when the backend reported them (no new call). const orgsSummary = formatOrgsSummary(connectivity.organizations); - if (orgsSummary) { - checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); - } + if (orgsSummary) checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); const orgBinding = formatOrgBinding(connectivity.org); - if (orgBinding) { - checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); - } - // Warn, not fail: the key works — it just cannot see the team's work, which - // otherwise looks like missing data rather than a scoping choice. + if (orgBinding) checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); const personalScopeHint = formatPersonalScopeHint(connectivity.organizations, connectivity.org); - if (personalScopeHint) { - checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); - } + if (personalScopeHint) checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); checks.push(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 (connectivity.v3Enabled === true) { - emitV3RoutingAdvisory(stderr); - } - - 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); - } + if (connectivity.v3Enabled === true) emitV3RoutingAdvisory(stderr); + if (failures > 0) throw new CLIError(`doctor: ${failures} check(s) failed, ${warnings} warning(s)`, 1); return report; } function checkNodeVersion(nodeVersion: string): DoctorCheck { - // 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: 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)`, + ? `v${nodeVersion} is outside the supported Node range ${SUPPORTED_NODE_RANGE}; upgrade Node.js` + : `v${nodeVersion} (${SUPPORTED_NODE_RANGE} supported)`, }; } @@ -175,167 +127,72 @@ function checkEndpoint(apiUrl: string): DoctorCheck { 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`, - }; + 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)', - }; + if (hasKey) return { name: 'Credentials', status: 'ok', detail: `API key configured (profile "${profile}")` }; + 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', - }; + 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<{ - check: DoctorCheck; - v3Enabled?: boolean; - organizations?: CliOrgSummary[]; - org?: CliOrgBinding; -}> { +): Promise<{ check: DoctorCheck; v3Enabled?: boolean; organizations?: CliOrgSummary[]; org?: CliOrgBinding }> { const name = 'Connectivity'; if (opts.dryRun) return { check: { name, status: 'warn', detail: 'skipped under --dry-run' } }; - if (!ctx.hasKey) - return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; - if (!ctx.endpointOk) - return { check: { name, status: 'warn', detail: 'skipped; endpoint URL is invalid' } }; - + if (!ctx.hasKey) return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; + if (!ctx.endpointOk) return { check: { 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 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 { - check: { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }, - v3Enabled: me.v3Enabled, - organizations: me.organizations, - org: me.org, - }; + return { check: { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }, v3Enabled: me.v3Enabled, organizations: me.organizations, org: me.org }; } catch (error) { if (error instanceof ApiError) { - if ( - error.code === 'AUTH_REQUIRED' || - error.code === 'AUTH_INVALID' || - error.code === 'AUTH_FORBIDDEN' - ) { - return { check: { name, status: 'fail', detail: `API key rejected (${error.code})` } }; - } + if (error.code === 'AUTH_REQUIRED' || error.code === 'AUTH_INVALID' || error.code === 'AUTH_FORBIDDEN') return { check: { name, status: 'fail', detail: `API key rejected (${error.code})` } }; return { check: { name, status: 'fail', detail: `GET /me failed (${error.code})` } }; } - return { - check: { - name, - status: 'fail', - detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})`, - }, - }; + return { check: { name, status: 'fail', detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})` } }; } } -const STATUS_LABEL: Record = { - ok: '[OK] ', - warn: '[WARN]', - fail: '[FAIL]', -}; +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}`); - } + 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).`, - ); + 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', - ) + .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); - }); - + .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: resolveOutputMode(globals.output), - endpointUrl: globals.endpointUrl, - debug: globals.debug ?? false, - verbose: globals.verbose ?? false, - dryRun: globals.dryRun ?? false, - requestTimeoutMs: parseRequestTimeoutFlag(globals.requestTimeout), - }; + const globals = command.optsWithGlobals() as Partial & { requestTimeout?: string }; + return { profile: globals.profile ?? 'default', output: resolveOutputMode(globals.output), 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) { - // 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}")`, - ); - } + if (!Number.isFinite(seconds) || seconds <= 0) throw localValidationError('request-timeout', `must be a positive number of seconds (got "${raw}")`); return Math.round(seconds * 1000); } From 93a3f4fb0048139b91d8f2bfa8a9a3fee1fe5c3e Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Wed, 12 Aug 2026 13:46:11 +0900 Subject: [PATCH 04/10] chore: restore doctor.ts to upstream formatting --- src/commands/doctor.ts | 207 ++++++++++++++++++++++++++++++++++------- 1 file changed, 175 insertions(+), 32 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 37aaa3ac..d53520a4 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -30,13 +30,15 @@ import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '.. import { isVerifySkillInstalled } from '../lib/skill-nudge.js'; import { emitV3RoutingAdvisory, routingLabel } from '../lib/v3-advisory.js'; import { VERSION } from '../version.js'; -import { SUPPORTED_NODE_RANGE, shouldRejectNodeVersion } from '../version-guard.js'; +import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js'; 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; } @@ -46,11 +48,14 @@ export interface DoctorReport { warnings: number; } +/** Minimal projection of `GET /me` we read for the connectivity detail. */ interface MeIdentity { userId?: string; keyId?: string; v3Enabled?: boolean; + /** Account-wide membership list. Absent-safe (older backends omit it). */ organizations?: CliOrgSummary[]; + /** The calling key's own org binding — membership keys only. */ org?: CliOrgBinding; } @@ -60,7 +65,9 @@ export interface DoctorDeps { 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; @@ -74,11 +81,20 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro 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 config = loadConfig({ + profile: opts.profile, + endpointUrl: opts.endpointUrl, + env, + credentialsPath: deps.credentialsPath, + }); const endpointCheck = checkEndpoint(config.apiUrl); const hasKey = Boolean(config.apiKey); const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); - const connectivity = await checkConnectivity(opts, deps, { hasKey, endpointOk: endpointCheck.status === 'ok' }); + + const connectivity = await checkConnectivity(opts, deps, { + hasKey, + endpointOk: endpointCheck.status === 'ok', + }); const checks: DoctorCheck[] = [ { name: 'CLI version', status: 'ok', detail: VERSION }, @@ -89,36 +105,68 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro connectivity.check, ]; + // Informational routing line, only when the backend reported it (no new call). if (connectivity.v3Enabled !== undefined) { const label = routingLabel(connectivity.v3Enabled); - checks.push({ name: 'Routing', status: 'ok', detail: connectivity.v3Enabled === true ? `${label} (V3 execution routing is ON)` : `${label} (default routing)` }); + checks.push({ + name: 'Routing', + status: 'ok', + detail: + connectivity.v3Enabled === true + ? `${label} (V3 execution routing is ON)` + : `${label} (default routing)`, + }); } + // Org attribution lines — only when the backend reported them (no new call). const orgsSummary = formatOrgsSummary(connectivity.organizations); - if (orgsSummary) checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); + if (orgsSummary) { + checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); + } const orgBinding = formatOrgBinding(connectivity.org); - if (orgBinding) checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); + if (orgBinding) { + checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); + } + // Warn, not fail: the key works — it just cannot see the team's work, which + // otherwise looks like missing data rather than a scoping choice. const personalScopeHint = formatPersonalScopeHint(connectivity.organizations, connectivity.org); - if (personalScopeHint) checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); + if (personalScopeHint) { + checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); + } checks.push(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 (connectivity.v3Enabled === true) emitV3RoutingAdvisory(stderr); - if (failures > 0) throw new CLIError(`doctor: ${failures} check(s) failed, ${warnings} warning(s)`, 1); + + if (connectivity.v3Enabled === true) { + emitV3RoutingAdvisory(stderr); + } + + 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 { + // 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: rejected ? 'fail' : 'ok', detail: rejected - ? `v${nodeVersion} is outside the supported Node range ${SUPPORTED_NODE_RANGE}; upgrade Node.js` - : `v${nodeVersion} (${SUPPORTED_NODE_RANGE} supported)`, + ? `v${nodeVersion} is below the required Node ${MIN_SUPPORTED_NODE_MAJOR}; upgrade Node.js` + : `v${nodeVersion} (>=${MIN_SUPPORTED_NODE_MAJOR} required)`, }; } @@ -127,72 +175,167 @@ function checkEndpoint(apiUrl: string): DoctorCheck { 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` }; + 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) return { name: 'Credentials', status: 'ok', detail: `API key configured (profile "${profile}")` }; - 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)' }; + 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' }; + 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<{ check: DoctorCheck; v3Enabled?: boolean; organizations?: CliOrgSummary[]; org?: CliOrgBinding }> { +): Promise<{ + check: DoctorCheck; + v3Enabled?: boolean; + organizations?: CliOrgSummary[]; + org?: CliOrgBinding; +}> { const name = 'Connectivity'; if (opts.dryRun) return { check: { name, status: 'warn', detail: 'skipped under --dry-run' } }; - if (!ctx.hasKey) return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; - if (!ctx.endpointOk) return { check: { name, status: 'warn', detail: 'skipped; endpoint URL is invalid' } }; + if (!ctx.hasKey) + return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; + if (!ctx.endpointOk) + return { check: { 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 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 { check: { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }, v3Enabled: me.v3Enabled, organizations: me.organizations, org: me.org }; + return { + check: { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }, + v3Enabled: me.v3Enabled, + organizations: me.organizations, + org: me.org, + }; } catch (error) { if (error instanceof ApiError) { - if (error.code === 'AUTH_REQUIRED' || error.code === 'AUTH_INVALID' || error.code === 'AUTH_FORBIDDEN') return { check: { name, status: 'fail', detail: `API key rejected (${error.code})` } }; + if ( + error.code === 'AUTH_REQUIRED' || + error.code === 'AUTH_INVALID' || + error.code === 'AUTH_FORBIDDEN' + ) { + return { check: { name, status: 'fail', detail: `API key rejected (${error.code})` } }; + } return { check: { name, status: 'fail', detail: `GET /me failed (${error.code})` } }; } - return { check: { name, status: 'fail', detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})` } }; + return { + check: { + name, + status: 'fail', + detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})`, + }, + }; } } -const STATUS_LABEL: Record = { ok: '[OK] ', warn: '[WARN]', fail: '[FAIL]' }; +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}`); + 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).`); + 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') + .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); }); + .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: resolveOutputMode(globals.output), endpointUrl: globals.endpointUrl, debug: globals.debug ?? false, verbose: globals.verbose ?? false, dryRun: globals.dryRun ?? false, requestTimeoutMs: parseRequestTimeoutFlag(globals.requestTimeout) }; + const globals = command.optsWithGlobals() as Partial & { + requestTimeout?: string; + }; + return { + profile: globals.profile ?? 'default', + output: resolveOutputMode(globals.output), + 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) throw localValidationError('request-timeout', `must be a positive number of seconds (got "${raw}")`); + 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); } From b92a2f15e5abfcefeffd150741126a5257a5aae6 Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Wed, 12 Aug 2026 15:12:47 +0900 Subject: [PATCH 05/10] fix: align doctor Node range messaging --- src/commands/doctor.ts | 215 +++++++---------------------------------- 1 file changed, 33 insertions(+), 182 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d53520a4..e0a721ab 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -30,7 +30,7 @@ import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '.. import { isVerifySkillInstalled } from '../lib/skill-nudge.js'; import { emitV3RoutingAdvisory, routingLabel } from '../lib/v3-advisory.js'; import { VERSION } from '../version.js'; -import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js'; +import { SUPPORTED_NODE_RANGE, shouldRejectNodeVersion } from '../version-guard.js'; export type DoctorStatus = 'ok' | 'warn' | 'fail'; @@ -53,9 +53,7 @@ interface MeIdentity { userId?: string; keyId?: string; v3Enabled?: boolean; - /** Account-wide membership list. Absent-safe (older backends omit it). */ organizations?: CliOrgSummary[]; - /** The calling key's own org binding — membership keys only. */ org?: CliOrgBinding; } @@ -65,9 +63,7 @@ export interface DoctorDeps { 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; @@ -81,20 +77,11 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro 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 config = loadConfig({ profile: opts.profile, endpointUrl: opts.endpointUrl, env, credentialsPath: deps.credentialsPath }); const endpointCheck = checkEndpoint(config.apiUrl); const hasKey = Boolean(config.apiKey); const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); - - const connectivity = await checkConnectivity(opts, deps, { - hasKey, - endpointOk: endpointCheck.status === 'ok', - }); + const connectivity = await checkConnectivity(opts, deps, { hasKey, endpointOk: endpointCheck.status === 'ok' }); const checks: DoctorCheck[] = [ { name: 'CLI version', status: 'ok', detail: VERSION }, @@ -105,237 +92,101 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro connectivity.check, ]; - // Informational routing line, only when the backend reported it (no new call). if (connectivity.v3Enabled !== undefined) { const label = routingLabel(connectivity.v3Enabled); - checks.push({ - name: 'Routing', - status: 'ok', - detail: - connectivity.v3Enabled === true - ? `${label} (V3 execution routing is ON)` - : `${label} (default routing)`, - }); + checks.push({ name: 'Routing', status: 'ok', detail: connectivity.v3Enabled === true ? `${label} (V3 execution routing is ON)` : `${label} (default routing)` }); } - - // Org attribution lines — only when the backend reported them (no new call). const orgsSummary = formatOrgsSummary(connectivity.organizations); - if (orgsSummary) { - checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); - } + if (orgsSummary) checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); const orgBinding = formatOrgBinding(connectivity.org); - if (orgBinding) { - checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); - } - // Warn, not fail: the key works — it just cannot see the team's work, which - // otherwise looks like missing data rather than a scoping choice. + if (orgBinding) checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); const personalScopeHint = formatPersonalScopeHint(connectivity.organizations, connectivity.org); - if (personalScopeHint) { - checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); - } - + if (personalScopeHint) checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); checks.push(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 (connectivity.v3Enabled === true) { - emitV3RoutingAdvisory(stderr); - } - - 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); - } + if (connectivity.v3Enabled === true) emitV3RoutingAdvisory(stderr); + if (failures > 0) throw new CLIError(`doctor: ${failures} check(s) failed, ${warnings} warning(s)`, 1); return report; } function checkNodeVersion(nodeVersion: string): DoctorCheck { - // 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: 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)`, + ? `v${nodeVersion} is outside the supported Node range ${SUPPORTED_NODE_RANGE}; upgrade Node.js` + : `v${nodeVersion} (supported range: ${SUPPORTED_NODE_RANGE})`, }; } 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`, - }; - } + 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)', - }; + if (hasKey) return { name: 'Credentials', status: 'ok', detail: `API key configured (profile "${profile}")` }; + 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', - }; + 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<{ - check: DoctorCheck; - v3Enabled?: boolean; - organizations?: CliOrgSummary[]; - org?: CliOrgBinding; -}> { +async function checkConnectivity(opts: CommonOptions, deps: DoctorDeps, ctx: { hasKey: boolean; endpointOk: boolean }): Promise<{ check: DoctorCheck; v3Enabled?: boolean; organizations?: CliOrgSummary[]; org?: CliOrgBinding }> { const name = 'Connectivity'; if (opts.dryRun) return { check: { name, status: 'warn', detail: 'skipped under --dry-run' } }; - if (!ctx.hasKey) - return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; - if (!ctx.endpointOk) - return { check: { name, status: 'warn', detail: 'skipped; endpoint URL is invalid' } }; - + if (!ctx.hasKey) return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; + if (!ctx.endpointOk) return { check: { 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 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 { - check: { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }, - v3Enabled: me.v3Enabled, - organizations: me.organizations, - org: me.org, - }; + return { check: { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }, v3Enabled: me.v3Enabled, organizations: me.organizations, org: me.org }; } catch (error) { if (error instanceof ApiError) { - if ( - error.code === 'AUTH_REQUIRED' || - error.code === 'AUTH_INVALID' || - error.code === 'AUTH_FORBIDDEN' - ) { - return { check: { name, status: 'fail', detail: `API key rejected (${error.code})` } }; - } + if (error.code === 'AUTH_REQUIRED' || error.code === 'AUTH_INVALID' || error.code === 'AUTH_FORBIDDEN') return { check: { name, status: 'fail', detail: `API key rejected (${error.code})` } }; return { check: { name, status: 'fail', detail: `GET /me failed (${error.code})` } }; } - return { - check: { - name, - status: 'fail', - detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})`, - }, - }; + return { check: { name, status: 'fail', detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})` } }; } } -const STATUS_LABEL: Record = { - ok: '[OK] ', - warn: '[WARN]', - fail: '[FAIL]', -}; +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}`); - } + 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).`, - ); + 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', - ) + .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); - }); - + .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: resolveOutputMode(globals.output), - endpointUrl: globals.endpointUrl, - debug: globals.debug ?? false, - verbose: globals.verbose ?? false, - dryRun: globals.dryRun ?? false, - requestTimeoutMs: parseRequestTimeoutFlag(globals.requestTimeout), - }; + const globals = command.optsWithGlobals() as Partial & { requestTimeout?: string }; + return { profile: globals.profile ?? 'default', output: resolveOutputMode(globals.output), 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) { - // 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}")`, - ); - } + if (!Number.isFinite(seconds) || seconds <= 0) throw localValidationError('request-timeout', `must be a positive number of seconds (got "${raw}")`); return Math.round(seconds * 1000); } From aee22f8f76d645231c3d4634b6579cb3d8a8bed0 Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Wed, 12 Aug 2026 15:13:32 +0900 Subject: [PATCH 06/10] chore: keep doctor diff scoped --- src/commands/doctor.ts | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index e0a721ab..fee303c9 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -30,7 +30,7 @@ import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '.. import { isVerifySkillInstalled } from '../lib/skill-nudge.js'; import { emitV3RoutingAdvisory, routingLabel } from '../lib/v3-advisory.js'; import { VERSION } from '../version.js'; -import { SUPPORTED_NODE_RANGE, shouldRejectNodeVersion } from '../version-guard.js'; +import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js'; export type DoctorStatus = 'ok' | 'warn' | 'fail'; @@ -53,7 +53,9 @@ interface MeIdentity { userId?: string; keyId?: string; v3Enabled?: boolean; + /** Account-wide membership list. Absent-safe (older backends omit it). */ organizations?: CliOrgSummary[]; + /** The calling key's own org binding — membership keys only. */ org?: CliOrgBinding; } @@ -63,7 +65,9 @@ export interface DoctorDeps { 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; @@ -77,11 +81,20 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro 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 config = loadConfig({ + profile: opts.profile, + endpointUrl: opts.endpointUrl, + env, + credentialsPath: deps.credentialsPath, + }); const endpointCheck = checkEndpoint(config.apiUrl); const hasKey = Boolean(config.apiKey); const stderr = deps.stderr ?? ((line: string) => process.stderr.write(`${line}\n`)); - const connectivity = await checkConnectivity(opts, deps, { hasKey, endpointOk: endpointCheck.status === 'ok' }); + + const connectivity = await checkConnectivity(opts, deps, { + hasKey, + endpointOk: endpointCheck.status === 'ok', + }); const checks: DoctorCheck[] = [ { name: 'CLI version', status: 'ok', detail: VERSION }, @@ -102,6 +115,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro if (orgBinding) checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); const personalScopeHint = formatPersonalScopeHint(connectivity.organizations, connectivity.org); if (personalScopeHint) checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); + checks.push(checkSkill(cwd, deps)); const failures = checks.filter(check => check.status === 'fail').length; @@ -119,8 +133,8 @@ function checkNodeVersion(nodeVersion: string): DoctorCheck { name: 'Node.js', status: rejected ? 'fail' : 'ok', detail: rejected - ? `v${nodeVersion} is outside the supported Node range ${SUPPORTED_NODE_RANGE}; upgrade Node.js` - : `v${nodeVersion} (supported range: ${SUPPORTED_NODE_RANGE})`, + ? `v${nodeVersion} is below the required Node ${MIN_SUPPORTED_NODE_MAJOR}; upgrade Node.js` + : `v${nodeVersion} (>=${MIN_SUPPORTED_NODE_MAJOR} required)`, }; } @@ -139,7 +153,11 @@ function checkSkill(cwd: string, deps: DoctorDeps): DoctorCheck { 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<{ check: DoctorCheck; v3Enabled?: boolean; organizations?: CliOrgSummary[]; org?: CliOrgBinding }> { +async function checkConnectivity( + opts: CommonOptions, + deps: DoctorDeps, + ctx: { hasKey: boolean; endpointOk: boolean }, +): Promise<{ check: DoctorCheck; v3Enabled?: boolean; organizations?: CliOrgSummary[]; org?: CliOrgBinding }> { const name = 'Connectivity'; if (opts.dryRun) return { check: { name, status: 'warn', detail: 'skipped under --dry-run' } }; if (!ctx.hasKey) return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; From 887a324a4f5c87fe1b9673af751dd3157416ffb6 Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Wed, 12 Aug 2026 16:11:58 +0900 Subject: [PATCH 07/10] fix: restore doctor formatting before minimal node patch --- src/commands/doctor.ts | 187 +++++++++++++++++++++++++++++++++++------ 1 file changed, 159 insertions(+), 28 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index fee303c9..d53520a4 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -105,29 +105,61 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro connectivity.check, ]; + // Informational routing line, only when the backend reported it (no new call). if (connectivity.v3Enabled !== undefined) { const label = routingLabel(connectivity.v3Enabled); - checks.push({ name: 'Routing', status: 'ok', detail: connectivity.v3Enabled === true ? `${label} (V3 execution routing is ON)` : `${label} (default routing)` }); + checks.push({ + name: 'Routing', + status: 'ok', + detail: + connectivity.v3Enabled === true + ? `${label} (V3 execution routing is ON)` + : `${label} (default routing)`, + }); } + + // Org attribution lines — only when the backend reported them (no new call). const orgsSummary = formatOrgsSummary(connectivity.organizations); - if (orgsSummary) checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); + if (orgsSummary) { + checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); + } const orgBinding = formatOrgBinding(connectivity.org); - if (orgBinding) checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); + if (orgBinding) { + checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); + } + // Warn, not fail: the key works — it just cannot see the team's work, which + // otherwise looks like missing data rather than a scoping choice. const personalScopeHint = formatPersonalScopeHint(connectivity.organizations, connectivity.org); - if (personalScopeHint) checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); + if (personalScopeHint) { + checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); + } checks.push(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 (connectivity.v3Enabled === true) emitV3RoutingAdvisory(stderr); - if (failures > 0) throw new CLIError(`doctor: ${failures} check(s) failed, ${warnings} warning(s)`, 1); + + if (connectivity.v3Enabled === true) { + emitV3RoutingAdvisory(stderr); + } + + 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 { + // 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', @@ -139,72 +171,171 @@ function checkNodeVersion(nodeVersion: string): DoctorCheck { } 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` }; } + 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) return { name: 'Credentials', status: 'ok', detail: `API key configured (profile "${profile}")` }; - 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)' }; + 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' }; + 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<{ check: DoctorCheck; v3Enabled?: boolean; organizations?: CliOrgSummary[]; org?: CliOrgBinding }> { +): Promise<{ + check: DoctorCheck; + v3Enabled?: boolean; + organizations?: CliOrgSummary[]; + org?: CliOrgBinding; +}> { const name = 'Connectivity'; if (opts.dryRun) return { check: { name, status: 'warn', detail: 'skipped under --dry-run' } }; - if (!ctx.hasKey) return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; - if (!ctx.endpointOk) return { check: { name, status: 'warn', detail: 'skipped; endpoint URL is invalid' } }; + if (!ctx.hasKey) + return { check: { name, status: 'warn', detail: 'skipped; no API key to test with' } }; + if (!ctx.endpointOk) + return { check: { 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 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 { check: { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }, v3Enabled: me.v3Enabled, organizations: me.organizations, org: me.org }; + return { + check: { name, status: 'ok', detail: `reached GET /me, API key accepted${who}` }, + v3Enabled: me.v3Enabled, + organizations: me.organizations, + org: me.org, + }; } catch (error) { if (error instanceof ApiError) { - if (error.code === 'AUTH_REQUIRED' || error.code === 'AUTH_INVALID' || error.code === 'AUTH_FORBIDDEN') return { check: { name, status: 'fail', detail: `API key rejected (${error.code})` } }; + if ( + error.code === 'AUTH_REQUIRED' || + error.code === 'AUTH_INVALID' || + error.code === 'AUTH_FORBIDDEN' + ) { + return { check: { name, status: 'fail', detail: `API key rejected (${error.code})` } }; + } return { check: { name, status: 'fail', detail: `GET /me failed (${error.code})` } }; } - return { check: { name, status: 'fail', detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})` } }; + return { + check: { + name, + status: 'fail', + detail: `GET /me failed (${error instanceof Error ? error.message : String(error)})`, + }, + }; } } -const STATUS_LABEL: Record = { ok: '[OK] ', warn: '[WARN]', fail: '[FAIL]' }; +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}`); + 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).`); + 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') + .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); }); + .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: resolveOutputMode(globals.output), endpointUrl: globals.endpointUrl, debug: globals.debug ?? false, verbose: globals.verbose ?? false, dryRun: globals.dryRun ?? false, requestTimeoutMs: parseRequestTimeoutFlag(globals.requestTimeout) }; + const globals = command.optsWithGlobals() as Partial & { + requestTimeout?: string; + }; + return { + profile: globals.profile ?? 'default', + output: resolveOutputMode(globals.output), + 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) throw localValidationError('request-timeout', `must be a positive number of seconds (got "${raw}")`); + 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); } From f30769d45561bd08addd0178e60d8b40b299ba1d Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Wed, 12 Aug 2026 18:08:01 +0900 Subject: [PATCH 08/10] fix: align doctor node range messaging --- src/commands/doctor.ts | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index d53520a4..f0ad1836 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -30,7 +30,7 @@ import { GLOBAL_OPTS_HINT, Output, resolveOutputMode, type OutputMode } from '.. import { isVerifySkillInstalled } from '../lib/skill-nudge.js'; import { emitV3RoutingAdvisory, routingLabel } from '../lib/v3-advisory.js'; import { VERSION } from '../version.js'; -import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js'; +import { SUPPORTED_NODE_RANGE, shouldRejectNodeVersion } from '../version-guard.js'; export type DoctorStatus = 'ok' | 'warn' | 'fail'; @@ -67,7 +67,7 @@ export interface DoctorDeps { 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`. */ + /** Runtime version string (e.g. "22.13.0"). Defaults to `process.versions.node`. */ nodeVersion?: string; existsSync?: (p: string) => boolean; readFileSync?: (p: string) => string; @@ -105,7 +105,6 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro connectivity.check, ]; - // Informational routing line, only when the backend reported it (no new call). if (connectivity.v3Enabled !== undefined) { const label = routingLabel(connectivity.v3Enabled); checks.push({ @@ -118,7 +117,6 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro }); } - // Org attribution lines — only when the backend reported them (no new call). const orgsSummary = formatOrgsSummary(connectivity.organizations); if (orgsSummary) { checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); @@ -127,8 +125,6 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro if (orgBinding) { checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); } - // Warn, not fail: the key works — it just cannot see the team's work, which - // otherwise looks like missing data rather than a scoping choice. const personalScopeHint = formatPersonalScopeHint(connectivity.organizations, connectivity.org); if (personalScopeHint) { checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); @@ -147,26 +143,20 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro } 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 { - // 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. + // Reuse the CLI runtime guard so doctor and startup enforce and describe the same range. const rejected = shouldRejectNodeVersion(nodeVersion); return { name: '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)`, + ? `v${nodeVersion} is outside the supported Node range ${SUPPORTED_NODE_RANGE}; upgrade Node.js` + : `v${nodeVersion} (supported range: ${SUPPORTED_NODE_RANGE})`, }; } @@ -185,14 +175,12 @@ function checkEndpoint(apiUrl: string): DoctorCheck { 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', @@ -329,8 +317,6 @@ function parseRequestTimeoutFlag(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const seconds = Number(raw); 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}")`, From fa86577faf411c1b1161d316a20c11e5b9bc0c4f Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Wed, 12 Aug 2026 19:59:52 +0900 Subject: [PATCH 09/10] fix: restore doctor comments outside node guard scope --- src/commands/doctor.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index f0ad1836..e79ea010 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -105,6 +105,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro connectivity.check, ]; + // Informational routing line, only when the backend reported it (no new call). if (connectivity.v3Enabled !== undefined) { const label = routingLabel(connectivity.v3Enabled); checks.push({ @@ -117,6 +118,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro }); } + // Org attribution lines — only when the backend reported them (no new call). const orgsSummary = formatOrgsSummary(connectivity.organizations); if (orgsSummary) { checks.push({ name: 'Organizations', status: 'ok', detail: orgsSummary }); @@ -125,6 +127,8 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro if (orgBinding) { checks.push({ name: 'Org binding', status: 'ok', detail: orgBinding }); } + // Warn, not fail: the key works — it just cannot see the team's work, which + // otherwise looks like missing data rather than a scoping choice. const personalScopeHint = formatPersonalScopeHint(connectivity.organizations, connectivity.org); if (personalScopeHint) { checks.push({ name: 'Workspace scope', status: 'warn', detail: personalScopeHint }); @@ -143,6 +147,9 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro } 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; @@ -175,12 +182,14 @@ function checkEndpoint(apiUrl: string): DoctorCheck { 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', @@ -317,6 +326,8 @@ function parseRequestTimeoutFlag(raw: string | undefined): number | undefined { if (raw === undefined) return undefined; const seconds = Number(raw); 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}")`, From ce5441b160fbf26de4a5ba74343afff1d2649eb7 Mon Sep 17 00:00:00 2001 From: AInoAKARI Date: Thu, 13 Aug 2026 19:54:01 +0900 Subject: [PATCH 10/10] test: align doctor fixtures with supported Node range --- src/commands/doctor.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 80f8c73b..c5c059af 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -50,7 +50,7 @@ function healthyDeps(credentialsPath: string, extra: Partial = {}): env: {}, credentialsPath, cwd: '/project', - nodeVersion: '22.9.0', + nodeVersion: '22.13.0', existsSync: () => true, // skill landing file present fetchImpl: makeFetch(OK_ME), ...extra, @@ -259,17 +259,17 @@ describe('runDoctor — failing checks exit non-zero', () => { expect(capture.stdout.join('\n')).toContain('GET /me failed (NOT_FOUND)'); }); - it('an outdated Node runtime fails the Node.js check', async () => { + it('an excluded in-range Node runtime fails the Node.js check', async () => { writeProfile('default', { apiKey: 'sk-user-abc' }, { path: credentialsPath }); const { capture, deps } = makeCapture(); const rejection = await runDoctor( { profile: 'default', output: 'text', debug: false }, - { ...healthyDeps(credentialsPath, { nodeVersion: '18.0.0' }), ...deps }, + { ...healthyDeps(credentialsPath, { nodeVersion: '22.9.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'); + expect(out).toContain('outside the supported Node range 20.19+, 22.13+, or 24+'); }); }); @@ -299,7 +299,7 @@ describe('runDoctor — warnings do not fail', () => { env: {}, credentialsPath, cwd: '/project', - nodeVersion: '22.9.0', + nodeVersion: '22.13.0', existsSync: () => true, fetchImpl, ...deps,