diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index 10f56fbf4c..11b4b28a9f 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -37,6 +37,7 @@ gittensory-mcp config --json gittensory-mcp status gittensory-mcp changelog gittensory-mcp doctor +gittensory-mcp doctor --exit-code gittensory-mcp profile list gittensory-mcp profile create work gittensory-mcp profile switch work @@ -128,6 +129,8 @@ Use `--profile ` on `login`, `logout`, `whoami`, `config`, `status`, and ` `gittensory-mcp config` prints the resolved effective configuration and the source that supplied each value (`environment`, `profile`, `config`, or `default`): the active API URL and its source, active profile and profile count, whether a config file is present and which environment variable steers its location, the cache-dir source, whether a token is configured and where it came from, and whether `GITTENSORY_UPLOAD_SOURCE` has enabled the unsupported source-upload setting. It never prints token values or local absolute paths. Add `--json` for machine-readable output. +By default `gittensory-mcp doctor` always exits 0. Pass `--exit-code` to make it exit non-zero when a diagnostic check fails (`status: "needs_attention"`), so it can gate a CI step or pre-commit hook. Warnings still exit 0. + ## Base-Agent Mode The agent commands are copilot-only. They rank, explain, preflight, and draft public-safe packets, but they do not edit code, open PRs, post comments, close, merge, or label from the local wrapper. diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 2f172686e7..c5937667ea 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -231,8 +231,8 @@ const agentRunIdShape = { }; if (cliArgs[0] && cliArgs[0] !== "--stdio") { - await runCli(cliArgs); - process.exit(0); + const exitCode = await runCli(cliArgs); + process.exit(typeof exitCode === "number" ? exitCode : 0); } const server = new McpServer({ @@ -1442,7 +1442,7 @@ function printHelp() { gittensory-mcp status [--profile name] [--json] gittensory-mcp profile list|create|switch|remove [name] [--json] gittensory-mcp changelog [--json] - gittensory-mcp doctor [--profile name] [--cwd path] [--json] + gittensory-mcp doctor [--profile name] [--cwd path] [--exit-code] [--json] gittensory-mcp cache status|clear [--json] gittensory-mcp init-client --print codex|claude|cursor|mcp [--agent-profile miner-planner|maintainer-triage|repo-owner-intake] [--json] gittensory-mcp decision-pack --login [--json] @@ -1868,6 +1868,9 @@ async function doctor(options) { } } } + // Opt-in: let `doctor` gate CI/pre-commit by exiting non-zero when a check fails. The default + // stays exit 0 so existing scripts that ignore the exit code keep working. + return options.exitCode && payload.status === "needs_attention" ? 1 : 0; } function doctorStatus(checks) { diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index c1a2a2e64a..b6c3da5ac0 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -1180,113 +1180,61 @@ describe("gittensory-mcp CLI", () => { expect(() => run(["completion", "powershell"])).toThrow(/Unsupported shell: powershell/); }); - it("reports resolved configuration provenance via config", () => { - const payload = JSON.parse(run(["config", "--json"])) as { - apiUrl: string; - apiUrlSource: string; - activeProfile: string; - profileCount: number; - configured: boolean; - configPathSource: string; - cacheDirSource: string; - tokenConfigured: boolean; - tokenSource: string; - sourceUpload: { default: boolean; enabled: boolean; source: string; supported: boolean }; - }; - // The run() harness sets GITTENSORY_CONFIG_DIR but no API URL or token. - expect(payload.apiUrl).toBe("https://gittensory-api.aethereal.dev"); - expect(payload.apiUrlSource).toBe("default"); - expect(payload.activeProfile).toBe("default"); - expect(payload.profileCount).toBeGreaterThanOrEqual(1); - expect(payload.configured).toBe(false); - expect(payload.configPathSource).toBe("GITTENSORY_CONFIG_DIR"); - expect(payload.cacheDirSource).toBe("default"); - expect(payload.tokenConfigured).toBe(false); - expect(payload.tokenSource).toBe("none"); - expect(payload.sourceUpload).toEqual({ default: false, enabled: false, source: "default", supported: false }); + it("keeps doctor exit code 0 by default even when a check fails", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + // No token configured -> the auth check fails -> status "needs_attention". + const payload = JSON.parse( + await runAsync(["doctor", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }), + ) as { status: string; checks: Array<{ name: string; status: string }> }; + expect(payload.status).toBe("needs_attention"); + expect(payload.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "auth", status: "fail" })])); }); - it("attributes config values to environment overrides without leaking secrets", () => { - const secretDir = mkdtempSync(join(tmpdir(), "gittensory-config-secret-")); + it("exits non-zero from doctor --exit-code when a check fails", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + let exitCode = 0; + let stdout = ""; try { - const out = run(["config"], { - GITTENSORY_API_URL: "https://example.test", - GITTENSORY_TOKEN: "super-secret-token", - GITTENSORY_CACHE_DIR: join(secretDir, "cache"), - GITTENSORY_CONFIG_DIR: secretDir, + stdout = execFileSync("node", [bin, "doctor", "--exit-code", "--json"], { + encoding: "utf8", + env: { + ...process.env, + GITTENSORY_API_TIMEOUT_MS: "1000", + GITTENSORY_API_URL: url, + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }, + stdio: ["ignore", "pipe", "pipe"], }); - expect(out).toContain("API URL: https://example.test (environment)"); - expect(out).toContain("Token: configured (environment)"); - expect(out).toContain("Cache dir: GITTENSORY_CACHE_DIR"); - expect(out).toContain("Source upload: disabled (unsupported)"); - // No token value or local absolute path may appear in output. - expect(out).not.toContain("super-secret-token"); - expect(out).not.toContain(secretDir); - } finally { - rmSync(secretDir, { recursive: true, force: true }); - } - }); - - it("reports enabled unsupported source upload environment settings via config", () => { - const payload = JSON.parse(run(["config", "--json"], { GITTENSORY_UPLOAD_SOURCE: "true" })) as { - sourceUpload: { default: boolean; enabled: boolean; source: string; supported: boolean }; - }; - expect(payload.sourceUpload).toEqual({ default: false, enabled: true, source: "GITTENSORY_UPLOAD_SOURCE", supported: false }); - - const out = run(["config"], { GITTENSORY_UPLOAD_SOURCE: "true" }); - expect(out).toContain("Source upload: enabled via GITTENSORY_UPLOAD_SOURCE (unsupported; unset GITTENSORY_UPLOAD_SOURCE)"); - }); - - it("attributes API URL and token to a named profile from the config file", () => { - const configDir = mkdtempSync(join(tmpdir(), "gittensory-config-profile-")); - try { - writeFileSync( - join(configDir, "config.json"), - JSON.stringify({ - activeProfile: "work", - profiles: { work: { apiUrl: "https://profile.example", session: { token: "tok", login: "octocat", expiresAt: "2099-01-01T00:00:00Z" } } }, - }), - { mode: 0o600 }, - ); - const payload = JSON.parse(run(["config", "--json"], { GITTENSORY_CONFIG_DIR: configDir })) as { - apiUrl: string; - apiUrlSource: string; - activeProfile: string; - configured: boolean; - tokenConfigured: boolean; - tokenSource: string; - profile: { login: string }; - }; - expect(payload.activeProfile).toBe("work"); - expect(payload.apiUrl).toBe("https://profile.example"); - expect(payload.apiUrlSource).toBe("profile"); - expect(payload.configured).toBe(true); - expect(payload.tokenConfigured).toBe(true); - expect(payload.tokenSource).toBe("profile"); - expect(payload.profile.login).toBe("octocat"); - } finally { - rmSync(configDir, { recursive: true, force: true }); + } catch (error) { + const execError = error as { status?: number | null; stdout?: string }; + exitCode = execError.status ?? 0; + stdout = execError.stdout ?? ""; } + expect(exitCode).toBe(1); + // The diagnostic report is still printed; only the process exit code changes. + expect((JSON.parse(stdout) as { status: string }).status).toBe("needs_attention"); }); - it("attributes API URL to a global config file reached through a config-path override", () => { - const dir = mkdtempSync(join(tmpdir(), "gittensory-config-global-")); - const file = join(dir, "custom-config.json"); - try { - writeFileSync(file, JSON.stringify({ apiUrl: "https://global.example" }), { mode: 0o600 }); - const payload = JSON.parse(run(["config", "--json"], { GITTENSORY_CONFIG_PATH: file, GITTENSORY_CONFIG_DIR: "" })) as { - apiUrl: string; - apiUrlSource: string; - configPathSource: string; - configured: boolean; - }; - expect(payload.apiUrl).toBe("https://global.example"); - expect(payload.apiUrlSource).toBe("config"); - expect(payload.configPathSource).toBe("GITTENSORY_CONFIG_PATH"); - expect(payload.configured).toBe(true); - } finally { - rmSync(dir, { recursive: true, force: true }); - } + it("keeps doctor --exit-code at 0 when checks pass", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + // runAsync resolves only on a zero exit code, so reaching the assertion proves exit 0. + const payload = JSON.parse( + await runAsync(["doctor", "--exit-code", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }), + ) as { status: string }; + expect(payload.status).toMatch(/ok|warnings/); }); });