Skip to content
Open
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
30 changes: 30 additions & 0 deletions src/commands/doctor-checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,36 @@ export async function checkJq(projectDir: string): Promise<CheckResult> {
};
}

export async function checkTimeout(projectDir: string): Promise<CheckResult> {
const label = "timeout command available";
// The Ralph loop wraps every agent call in `timeout`/`gtimeout`
// (see .ralph/lib/timeout_utils.sh). On macOS that means `gtimeout` from
// GNU coreutils; on Linux it is the built-in `timeout`. Without it the loop
// exits on its first iteration, so doctor must verify it the same way the
// loop resolves it: via `command -v` inside bash.
const probe =
process.platform === "darwin"
? "command -v gtimeout || command -v timeout"
: "command -v timeout";
const result = await runBashCommand(probe, { cwd: projectDir });
const available = result.exitCode === 0;
const resolved = result.stdout.trim().split("/").pop();
return {
label,
passed: available,
detail: available
? resolved || undefined
: "timeout/gtimeout not found in bash PATH",
hint: available
? undefined
: process.platform === "win32"
? "Install coreutils (provides timeout), e.g. via MSYS2: pacman -S coreutils"
: process.platform === "darwin"
? "Install GNU coreutils (provides gtimeout): brew install coreutils"
: "Install coreutils (provides timeout): sudo apt-get install coreutils",
};
}

export async function checkGitRepo(projectDir: string): Promise<CheckResult> {
const label = "git repository with commits";
const gitDir = await runBashCommand("git rev-parse --git-dir", { cwd: projectDir });
Expand Down
2 changes: 2 additions & 0 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
checkBash,
checkGitRepo,
checkJq,
checkTimeout,
checkConfig,
checkBmadDir,
checkRalphLoop,
Expand Down Expand Up @@ -119,6 +120,7 @@ const CORE_CHECKS: CheckDefinition[] = [
{ id: "bash-available", run: checkBash },
{ id: "git-repo", run: checkGitRepo },
{ id: "jq-available", run: checkJq },
{ id: "timeout-available", run: checkTimeout },
{ id: "config-valid", run: checkConfig },
{ id: "bmad-dir", run: checkBmadDir },
{ id: "ralph-loop", run: checkRalphLoop },
Expand Down
70 changes: 70 additions & 0 deletions tests/commands/doctor-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
checkNodeVersion,
checkBash,
checkJq,
checkTimeout,
checkGitRepo,
checkBmadDir,
checkRalphLoop,
Expand Down Expand Up @@ -221,6 +222,75 @@ describe("checkJq", () => {
});
});

describe("checkTimeout", () => {
it("uses the label 'timeout command available'", async () => {
mockedRunBashCommand.mockResolvedValue({
exitCode: 0,
stdout: "/usr/bin/timeout\n",
stderr: "",
});

const result = await checkTimeout("/projects/webapp");

expect(result.label).toBe("timeout command available");
});

it("passes and reports the resolved binary when available", async () => {
mockedRunBashCommand.mockResolvedValue({
exitCode: 0,
stdout: "/usr/bin/timeout\n",
stderr: "",
});

const result = await checkTimeout("/projects/webapp");

expect(result.passed).toBe(true);
expect(result.detail).toBe("timeout");
expect(result.hint).toBeUndefined();
});

it("probes for gtimeout first on macOS", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });

try {
mockedRunBashCommand.mockResolvedValue({
exitCode: 0,
stdout: "/opt/homebrew/bin/gtimeout\n",
stderr: "",
});

const result = await checkTimeout("/projects/webapp");

expect(mockedRunBashCommand).toHaveBeenCalledWith(
"command -v gtimeout || command -v timeout",
{ cwd: "/projects/webapp" }
);
expect(result.passed).toBe(true);
expect(result.detail).toBe("gtimeout");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
}
});

it("fails with a coreutils hint when no timeout binary is found on macOS", async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });

try {
mockedRunBashCommand.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "" });

const result = await checkTimeout("/projects/webapp");

expect(result.passed).toBe(false);
expect(result.detail).toBe("timeout/gtimeout not found in bash PATH");
expect(result.hint).toContain("brew install coreutils");
} finally {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
}
});
});

describe("checkDir", () => {
it("passes when the path is a directory", async () => {
mockStat.mockResolvedValue({ isDirectory: () => true } as ReturnType<
Expand Down
3 changes: 2 additions & 1 deletion tests/commands/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1030,7 +1030,7 @@ describe("doctor command", { timeout: 15000 }, () => {
const registry = buildCheckRegistry(claudeCodePlatform);

expect(Array.isArray(registry)).toBe(true);
expect(registry.length).toBe(18);
expect(registry.length).toBe(19);

// All checks should have required properties
for (const check of registry) {
Expand All @@ -1052,6 +1052,7 @@ describe("doctor command", { timeout: 15000 }, () => {
"bash-available",
"git-repo",
"jq-available",
"timeout-available",
"config-valid",
"bmad-dir",
"ralph-loop",
Expand Down