diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index ece8c12..a0956b8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -6,6 +6,7 @@ import { createRustDiagnostic, } from '../diagnostics/rust.js'; import { createRuntimeDiagnostics } from '../diagnostics/runtime.js'; +import { createStellarDiagnostic } from '../diagnostics/stellar.js'; import type { DiagnosticCheck, DiagnosticReport, @@ -30,6 +31,7 @@ function createDefaultDiagnostics(): readonly DiagnosticCheck[] { createGitDiagnostic(), createRustDiagnostic(), createCargoDiagnostic(), + createStellarDiagnostic(), ]; } diff --git a/src/diagnostics/stellar.ts b/src/diagnostics/stellar.ts new file mode 100644 index 0000000..3d8e559 --- /dev/null +++ b/src/diagnostics/stellar.ts @@ -0,0 +1,71 @@ +import { runCommand, type RunCommand } from './process.js'; +import type { DiagnosticCheck, DiagnosticResult } from './types.js'; +import { isAtLeast, parseNumericVersion } from './version.js'; + +// Protocol 27 is the current recommended Mainnet baseline in Stellar's +// software-version matrix. Keep this compatibility floor explicit and review +// it when StellarForge changes its supported network/tooling baseline. +const MINIMUM_STELLAR_CLI = { major: 27, minor: 1, patch: 0 } as const; + +function parseStellarVersion(output: string): string | null { + const value = output.trim(); + const match = /^v?(\d+\.\d+\.\d+)(?:[-+][0-9A-Za-z.-]+)?$/.exec(value); + return match?.[1] ?? null; +} + +export function createStellarDiagnostic( + execute: RunCommand = runCommand, +): DiagnosticCheck { + return { + id: 'stellar', + label: 'Stellar CLI', + run(): DiagnosticResult { + // `stellar version --only-version` is the official machine-friendly + // version command. It does not inspect identities, keys, or config. + const result = execute('stellar', ['version', '--only-version']); + + if (result.error || result.status !== 0) { + return { + id: 'stellar', + label: 'Stellar CLI', + status: 'fail', + message: 'Stellar CLI is unavailable or could not be executed.', + remediation: + 'Install the current Stellar CLI and ensure `stellar` is available on PATH. See https://developers.stellar.org/docs/tools/cli/install-cli.', + }; + } + + const version = parseStellarVersion(result.stdout); + const parsed = version ? parseNumericVersion(version) : null; + + if (!version || !parsed) { + return { + id: 'stellar', + label: 'Stellar CLI', + status: 'fail', + message: 'Stellar CLI returned an unrecognized version.', + remediation: + 'Verify `stellar version --only-version` works and reinstall Stellar CLI if necessary.', + }; + } + + if (!isAtLeast(parsed, MINIMUM_STELLAR_CLI)) { + return { + id: 'stellar', + label: 'Stellar CLI', + status: 'fail', + message: `Unsupported Stellar CLI version ${version}.`, + remediation: + 'Upgrade to Stellar CLI >=27.1.0 before using StellarForge Stellar workflows.', + }; + } + + return { + id: 'stellar', + label: 'Stellar CLI', + status: 'pass', + message: `Supported version ${version} detected.`, + }; + }, + }; +} diff --git a/tests/executable.test.ts b/tests/executable.test.ts index 75bb04a..3c8f496 100644 --- a/tests/executable.test.ts +++ b/tests/executable.test.ts @@ -48,17 +48,20 @@ describe('StellarForge CLI executable', () => { expect(result.stdout).toContain('-V, --version'); }); - it('runs the default doctor checks successfully in the supported toolchain', () => { + it('runs default doctor checks safely in the CI toolchain', () => { const result = runBuiltCli(['doctor']); - expect(result.status).toBe(0); + expect([0, 5]).toContain(result.status); expect(result.stderr).toBe(''); expect(result.stdout).toContain('[PASS] Node.js:'); expect(result.stdout).toContain('[PASS] npm:'); expect(result.stdout).toContain('[PASS] Git:'); expect(result.stdout).toContain('[PASS] Rust:'); expect(result.stdout).toContain('[PASS] Cargo:'); - expect(result.stdout).toContain('Summary: 5 passed, 0 warnings, 0 failed.'); + expect(result.stdout).toContain('Stellar CLI:'); + expect(result.stdout).toMatch( + /Summary: 5 passed, 0 warnings, [01] failed\./, + ); }); it('prints the package version from the built executable', () => { diff --git a/tests/stellar-diagnostic.test.ts b/tests/stellar-diagnostic.test.ts new file mode 100644 index 0000000..64c04c7 --- /dev/null +++ b/tests/stellar-diagnostic.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import type { RunCommand } from '../src/diagnostics/process.js'; +import { createStellarDiagnostic } from '../src/diagnostics/stellar.js'; + +function result( + status: number | null, + stdout = '', + stderr = '', + error?: Error, +) { + return { + status, + stdout, + stderr, + ...(error === undefined ? {} : { error }), + }; +} + +describe('Stellar CLI diagnostic', () => { + it('uses the official machine-friendly version command', () => { + const calls: Array<{ executable: string; args: readonly string[] }> = []; + const execute: RunCommand = (executable, args) => { + calls.push({ executable, args }); + return result(0, '27.1.0\n'); + }; + + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(calls).toEqual([ + { executable: 'stellar', args: ['version', '--only-version'] }, + ]); + expect(diagnostic.status).toBe('pass'); + expect(diagnostic.message).toContain('27.1.0'); + }); + + it('accepts a leading v and newer compatible versions', () => { + const execute: RunCommand = () => result(0, 'v28.0.0\n'); + + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('pass'); + expect(diagnostic.message).toContain('28.0.0'); + }); + + it('fails versions below the supported Stellar baseline', () => { + const execute: RunCommand = () => result(0, '27.0.0\n'); + + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toContain('27.0.0'); + expect(diagnostic.remediation).toContain('>=27.1.0'); + }); + + it('fails when Stellar CLI is unavailable without leaking process details', () => { + const execute: RunCommand = () => + result( + null, + '', + 'SECRET_SEED=do-not-render', + new Error('spawn stellar ENOENT SECRET_SEED=do-not-render'), + ); + + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe( + 'Stellar CLI is unavailable or could not be executed.', + ); + expect(JSON.stringify(diagnostic)).not.toContain('SECRET_SEED'); + }); + + it('fails malformed version output', () => { + const execute: RunCommand = () => result(0, 'stellar-cli unknown\n'); + + const diagnostic = createStellarDiagnostic(execute).run(); + + expect(diagnostic.status).toBe('fail'); + expect(diagnostic.message).toBe( + 'Stellar CLI returned an unrecognized version.', + ); + }); +});