Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/git-doctor-diagnostic.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stellarforge/cli": minor
---

Add Git availability and version detection to `stellarforge doctor`, including safe failure messages and remediation guidance.
9 changes: 7 additions & 2 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Command } from 'commander';
import { EXIT_CODES } from '../errors/errors.js';
import { createGitDiagnostic } from '../diagnostics/git.js';
import { runDiagnostics } from '../diagnostics/run.js';
import { createRuntimeDiagnostics } from '../diagnostics/runtime.js';
import type {
DiagnosticCheck,
DiagnosticReport,
DiagnosticResult,
} from '../diagnostics/types.js';
import { EXIT_CODES } from '../errors/errors.js';
import { TerminalOutput } from '../output/terminal.js';

export interface DoctorCommandOptions {
Expand All @@ -19,6 +20,10 @@ function formatResult(result: DiagnosticResult): string {
return `[${result.status.toUpperCase()}] ${result.label}: ${result.message}`;
}

function createDefaultDiagnostics(): readonly DiagnosticCheck[] {
return [...createRuntimeDiagnostics(), createGitDiagnostic()];
}

export function renderDiagnosticReport(
report: DiagnosticReport,
output: TerminalOutput,
Expand All @@ -43,7 +48,7 @@ export function renderDiagnosticReport(
export function createDoctorCommand(
options: DoctorCommandOptions = {},
): Command {
const checks = options.checks ?? createRuntimeDiagnostics();
const checks = options.checks ?? createDefaultDiagnostics();
const output = options.output ?? new TerminalOutput();
const setExitCode =
options.setExitCode ?? ((code) => (process.exitCode = code));
Expand Down
49 changes: 49 additions & 0 deletions src/diagnostics/git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { runCommand, type RunCommand } from './process.js';
import type { DiagnosticCheck, DiagnosticResult } from './types.js';

function parseGitVersion(output: string): string | null {
const match = /^git version (\d+\.\d+\.\d+)/i.exec(output.trim());
return match?.[1] ?? null;
}

export function createGitDiagnostic(
execute: RunCommand = runCommand,
): DiagnosticCheck {
return {
id: 'git',
label: 'Git',
run(): DiagnosticResult {
const result = execute('git', ['--version']);

if (result.error || result.status !== 0) {
return {
id: 'git',
label: 'Git',
status: 'fail',
message: 'Git is unavailable or could not be executed.',
remediation: 'Install Git and ensure `git` is available on PATH.',
};
}

const version = parseGitVersion(result.stdout);

if (!version) {
return {
id: 'git',
label: 'Git',
status: 'fail',
message: 'Git returned an unrecognized version.',
remediation:
'Verify `git --version` works and reinstall Git if necessary.',
};
}

return {
id: 'git',
label: 'Git',
status: 'pass',
message: `Version ${version} detected.`,
};
},
};
}
5 changes: 3 additions & 2 deletions tests/executable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,15 @@ describe('StellarForge CLI executable', () => {
expect(result.stdout).toContain('-V, --version');
});

it('runs the default runtime doctor checks successfully in the supported toolchain', () => {
it('runs the default doctor checks successfully in the supported toolchain', () => {
const result = runBuiltCli(['doctor']);

expect(result.status).toBe(0);
expect(result.stderr).toBe('');
expect(result.stdout).toContain('[PASS] Node.js:');
expect(result.stdout).toContain('[PASS] npm:');
expect(result.stdout).toContain('Summary: 2 passed, 0 warnings, 0 failed.');
expect(result.stdout).toContain('[PASS] Git:');
expect(result.stdout).toContain('Summary: 3 passed, 0 warnings, 0 failed.');
});

it('prints the package version from the built executable', () => {
Expand Down
71 changes: 71 additions & 0 deletions tests/git-diagnostic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, expect, it } from 'vitest';
import { createGitDiagnostic } from '../src/diagnostics/git.js';
import type { RunCommand } from '../src/diagnostics/process.js';

function result(
status: number | null,
stdout = '',
stderr = '',
error?: Error,
) {
return {
status,
stdout,
stderr,
...(error === undefined ? {} : { error }),
};
}

describe('Git diagnostic', () => {
it('executes git with an argument array and parses standard version output', () => {
const calls: Array<{ executable: string; args: readonly string[] }> = [];
const execute: RunCommand = (executable, args) => {
calls.push({ executable, args });
return result(0, 'git version 2.47.1\n');
};

const diagnostic = createGitDiagnostic(execute).run();

expect(calls).toEqual([{ executable: 'git', args: ['--version'] }]);
expect(diagnostic.status).toBe('pass');
expect(diagnostic.message).toContain('2.47.1');
});

it('accepts platform-suffixed Git version output', () => {
const execute: RunCommand = () =>
result(0, 'git version 2.47.1.windows.1\n');

const diagnostic = createGitDiagnostic(execute).run();

expect(diagnostic.status).toBe('pass');
expect(diagnostic.message).toContain('2.47.1');
});

it('fails when Git is unavailable without leaking process details', () => {
const execute: RunCommand = () =>
result(
null,
'',
'SECRET_TOKEN=do-not-render',
new Error('spawn git ENOENT SECRET_TOKEN=do-not-render'),
);

const diagnostic = createGitDiagnostic(execute).run();

expect(diagnostic.status).toBe('fail');
expect(diagnostic.message).toBe(
'Git is unavailable or could not be executed.',
);
expect(JSON.stringify(diagnostic)).not.toContain('SECRET_TOKEN');
});

it('fails malformed Git version output with remediation guidance', () => {
const execute: RunCommand = () => result(0, 'git version unknown\n');

const diagnostic = createGitDiagnostic(execute).run();

expect(diagnostic.status).toBe('fail');
expect(diagnostic.message).toBe('Git returned an unrecognized version.');
expect(diagnostic.remediation).toContain('git --version');
});
});