From e580bf1f6891d369b356ce5655a08b2cd1ace0b7 Mon Sep 17 00:00:00 2001 From: mkdev11 Date: Fri, 5 Jun 2026 15:56:55 +0200 Subject: [PATCH] feat(mcp): group doctor onboarding checks --- packages/gittensory-mcp/bin/gittensory-mcp.js | 133 +++++++++++++++++- test/unit/mcp-cli.test.ts | 110 ++++++++++++++- 2 files changed, 238 insertions(+), 5 deletions(-) diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 94911864..a69b4715 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -1671,6 +1671,8 @@ async function doctor(options) { remediation: sanitizeDiagnosticText(remediation, [options.cwd]), }), ); + let authLogin = options.login ?? activeProfile.session?.login; + let repoFullName = typeof options.repo === "string" ? options.repo : undefined; let health = null; try { @@ -1718,6 +1720,7 @@ async function doctor(options) { } else { try { const session = await apiGet("/v1/auth/session"); + authLogin = session.login ?? authLogin; add("auth", "pass", `Profile ${activeProfileName} authenticated as ${session.login}; session expires ${session.expiresAt}.`); } catch (error) { add("auth", "warn", `A token is configured for profile ${activeProfileName} but no user session was verified: ${error instanceof Error ? error.message : "session_check_failed"}.`, "If this is a static beta token, this can be expected. Otherwise run `gittensory-mcp login`."); @@ -1745,6 +1748,7 @@ async function doctor(options) { repoFullName: options.repo, login: options.login ?? activeProfile.session?.login ?? "local", }); + repoFullName = metadata.repoFullName ?? repoFullName; add("git_metadata", "pass", `${metadata.repoFullName} on ${metadata.branchName}; ${metadata.changedFiles.length} changed file(s).`); } catch (error) { add("git_metadata", "warn", error instanceof Error ? error.message : "git_metadata_failed", "Run from a git repo or pass --repo owner/repo."); @@ -1778,26 +1782,147 @@ async function doctor(options) { add("gittensor_root", "warn", "Python gittensor scorer is configured but GITTENSOR_ROOT is unset.", "Set GITTENSOR_ROOT to a local entrius/gittensor checkout."); } + const statusValue = doctorStatus(checks); + const checklist = buildDoctorChecklist(checks, { + status: statusValue, + profileName: activeProfileName, + login: authLogin, + repoFullName, + }); + const nextCommand = checklist.find((group) => group.id === "next_command")?.nextCommand; const payload = { - status: checks.some((check) => check.status === "fail") ? "needs_attention" : checks.some((check) => check.status === "warn") ? "warnings" : "ok", + status: statusValue, apiUrl, profile: profilePublicState(activeProfileName), config: { configured: existsSync(configPath), activeProfile: activeProfileName, profileCount: profileList(config).length }, decisionPackCache, sourceUploadSupported: false, + checklist, + nextCommand, checks, }; if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); else { process.stdout.write(`Gittensory doctor: ${payload.status}\n`); process.stdout.write(`Profile: ${activeProfileName}\n`); - for (const check of checks) { - process.stdout.write(`- ${check.status}: ${check.name} - ${check.detail}\n`); - if (check.remediation) process.stdout.write(` ${check.remediation}\n`); + for (const group of checklist) { + process.stdout.write(`\n${group.title}: ${group.status}\n`); + if (group.id === "next_command") { + process.stdout.write(`- ${group.detail}\n`); + if (group.nextCommand?.command) process.stdout.write(` ${group.nextCommand.command}\n`); + continue; + } + for (const check of group.checks ?? []) { + process.stdout.write(`- ${check.status}: ${check.name} - ${check.detail}\n`); + if (check.remediation) process.stdout.write(` ${check.remediation}\n`); + } } } } +function doctorStatus(checks) { + if (checks.some((check) => check.status === "fail")) return "needs_attention"; + if (checks.some((check) => check.status === "warn")) return "warnings"; + return "ok"; +} + +function buildDoctorChecklist(checks, context) { + const byName = new Map(checks.map((check) => [check.name, check])); + const groups = doctorChecklistGroups().map((group) => { + const groupChecks = group.checks.map((name) => byName.get(name)).filter(Boolean); + return stripUndefined({ + id: group.id, + title: group.title, + status: checklistStatus(groupChecks), + checks: groupChecks, + }); + }); + const nextCommand = doctorNextCommand(byName, context); + return [ + ...groups, + stripUndefined({ + id: "next_command", + title: "Next command", + status: context.status === "needs_attention" ? "fail" : context.status === "warnings" ? "warn" : "pass", + detail: nextCommand.reason, + nextCommand, + }), + ]; +} + +function doctorChecklistGroups() { + return [ + { id: "auth", title: "Auth", checks: ["auth"] }, + { id: "api_compatibility", title: "API compatibility", checks: ["api_health", "version", "api_compatibility"] }, + { id: "local_repo_readiness", title: "Local repo readiness", checks: ["git_metadata", "client_path"] }, + { id: "scorer_availability", title: "Scorer availability", checks: ["local_scorer", "gittensor_root"] }, + { id: "output_safety", title: "Output safety", checks: ["source_upload", "decision_pack_cache"] }, + ]; +} + +function checklistStatus(checks) { + if (checks.some((check) => check.status === "fail")) return "fail"; + if (checks.some((check) => check.status === "warn")) return "warn"; + return "pass"; +} + +function doctorNextCommand(byName, context) { + const sourceUpload = byName.get("source_upload"); + if (sourceUpload?.status === "fail") { + return { + command: "unset GITTENSORY_UPLOAD_SOURCE", + reason: "Disable source upload first; the local MCP wrapper only sends metadata.", + }; + } + const apiCompatibility = byName.get("api_compatibility"); + if (apiCompatibility?.status === "fail") { + return { + command: apiCompatibility.remediation ?? upgradeCommand, + reason: "Upgrade the MCP package before relying on API-backed commands.", + }; + } + const auth = byName.get("auth"); + if (auth?.status === "fail") { + return { + command: `gittensory-mcp login --profile ${context.profileName}`, + reason: "Authenticate the active profile so doctor, plan, preflight, and packet commands can call the API.", + }; + } + const apiHealth = byName.get("api_health"); + if (apiHealth?.status === "fail") { + return { + command: "gittensory-mcp status --json", + reason: "Check API reachability before running planner or preflight commands.", + }; + } + const version = byName.get("version"); + if (version?.status === "warn" && version.remediation?.includes("npm install")) { + return { + command: upgradeCommand, + reason: "Update the MCP package so local behavior matches the current API.", + }; + } + const gitMetadata = byName.get("git_metadata"); + if (gitMetadata?.status === "warn") { + return { + command: "gittensory-mcp doctor --repo owner/repo --json", + reason: "Run doctor from a git checkout or pass the repository explicitly.", + }; + } + const localScorer = byName.get("local_scorer"); + if (localScorer?.status === "warn" && localScorer.remediation) { + const scorerSetupCommand = localScorer.remediation.startsWith("Example: ") ? localScorer.remediation.replace(/^Example:\s*/, "") : "gittensory-mcp doctor --json"; + return { + command: scorerSetupCommand, + reason: "Configure the optional local scorer for richer private branch analysis.", + }; + } + return { + command: `gittensory-mcp preflight --login ${context.login ?? ""} --repo ${context.repoFullName ?? "owner/repo"} --json`, + reason: "Run branch preflight next; source upload remains disabled.", + }; +} + function initClient(options) { const client = String(options.print ?? options.client ?? "").toLowerCase(); if (!client) throw new Error("Pass --print codex, --print claude, --print cursor, or --print mcp."); diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 081c2604..ffcac740 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -50,12 +50,32 @@ describe("gittensory-mcp CLI", () => { GITTENSOR_SCORE_PREVIEW_CMD: `node ${join(process.cwd(), "test/fixtures/local-scorer/scorer-malformed.mjs")}`, GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", }), - ) as { status: string; config: { configured: boolean }; checks: Array<{ name: string; status: string; detail: string; remediation?: string }> }; + ) as { + status: string; + config: { configured: boolean }; + checklist: Array<{ id: string; title: string; status: string; checks?: Array<{ name: string; status: string; detail: string; remediation?: string }> }>; + nextCommand: { command: string; reason: string }; + checks: Array<{ name: string; status: string; detail: string; remediation?: string }>; + }; const serialized = JSON.stringify(payload); expect(payload.status).toMatch(/ok|warnings/); expect(serialized).not.toMatch(/secret-gittensor|secret-config/); expect(payload.config.configured).toBe(true); + expect(payload.checklist).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "auth", title: "Auth", status: "pass" }), + expect.objectContaining({ id: "api_compatibility", title: "API compatibility", status: "pass" }), + expect.objectContaining({ id: "local_repo_readiness", title: "Local repo readiness", status: "pass" }), + expect.objectContaining({ id: "scorer_availability", title: "Scorer availability", status: "warn" }), + expect.objectContaining({ id: "output_safety", title: "Output safety", status: "pass" }), + expect.objectContaining({ id: "next_command", title: "Next command", status: "warn" }), + ]), + ); + expect(payload.nextCommand).toMatchObject({ + command: "gittensory-mcp doctor --json", + reason: expect.stringContaining("local scorer"), + }); expect(payload.checks).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "api_health", status: "pass" }), @@ -73,6 +93,35 @@ describe("gittensory-mcp CLI", () => { expect(localScorer?.detail).not.toMatch(join(process.cwd(), "test/fixtures")); }); + it("uses doctor as a first-run auth checklist when no local session is configured", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const payload = JSON.parse( + await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_API_TOKEN: "", + GITTENSORY_TOKEN: "", + GITTENSORY_MCP_TOKEN: "", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }), + ) as { + status: string; + checklist: Array<{ id: string; status: string; checks?: Array<{ name: string; status: string }> }>; + nextCommand: { command: string; reason: string }; + }; + + const auth = payload.checklist.find((group) => group.id === "auth"); + expect(payload.status).toBe("needs_attention"); + expect(auth).toMatchObject({ status: "fail" }); + expect(auth?.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "auth", status: "fail" })])); + expect(payload.nextCommand).toMatchObject({ + command: "gittensory-mcp login --profile default", + reason: expect.stringContaining("Authenticate"), + }); + expect(JSON.stringify(payload)).not.toContain(tempDir); + }); + it("reports a stale global install with an exact upgrade command and npx fallback", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const url = await startFixtureServer({ latestVersion: "9.9.9" }); @@ -266,6 +315,8 @@ describe("gittensory-mcp CLI", () => { }); const doctor = JSON.parse(await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], env)) as { + checklist: Array<{ id: string; status: string }>; + nextCommand: { command: string; reason: string }; checks: Array<{ name: string; status: string; remediation?: string }>; }; expect(doctor.checks).toEqual( @@ -277,6 +328,63 @@ describe("gittensory-mcp CLI", () => { }), ]), ); + expect(doctor.checklist).toEqual(expect.arrayContaining([expect.objectContaining({ id: "api_compatibility", status: "fail" })])); + expect(doctor.nextCommand).toMatchObject({ + command: "npm install -g @jsonbored/gittensory-mcp@latest", + reason: expect.stringContaining("Upgrade"), + }); + }); + + it("keeps source upload unsupported and fail-closed in the doctor checklist", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const payload = JSON.parse( + await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + GITTENSORY_UPLOAD_SOURCE: "true", + }), + ) as { + sourceUploadSupported: boolean; + checklist: Array<{ id: string; status: string; checks?: Array<{ name: string; status: string; remediation?: string }> }>; + nextCommand: { command: string; reason: string }; + }; + + const outputSafety = payload.checklist.find((group) => group.id === "output_safety"); + expect(payload.sourceUploadSupported).toBe(false); + expect(outputSafety).toMatchObject({ status: "fail" }); + expect(outputSafety?.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "source_upload", status: "fail" })])); + expect(payload.nextCommand).toMatchObject({ + command: "unset GITTENSORY_UPLOAD_SOURCE", + reason: expect.stringContaining("metadata"), + }); + }); + + it("points missing local repo readiness at an explicit repo-aware doctor command", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const url = await startFixtureServer(); + const payload = JSON.parse( + await runAsync(["doctor", "--cwd", tempDir, "--json"], { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }), + ) as { + checklist: Array<{ id: string; status: string; checks?: Array<{ name: string; status: string; detail: string }> }>; + nextCommand: { command: string; reason: string }; + }; + + const repoReadiness = payload.checklist.find((group) => group.id === "local_repo_readiness"); + expect(repoReadiness).toMatchObject({ status: "warn" }); + expect(repoReadiness?.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "git_metadata", status: "warn" })])); + expect(payload.nextCommand).toMatchObject({ + command: "gittensory-mcp doctor --repo owner/repo --json", + reason: expect.stringContaining("git checkout"), + }); + expect(JSON.stringify(payload)).not.toContain(tempDir); }); it("does not print configured tokens or local absolute paths in status or doctor output", async () => {