diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index ef027aff7d..ce6dd2186f 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -1886,7 +1886,7 @@ function doctorNextCommand(byName, context) { const auth = byName.get("auth"); if (auth?.status === "fail") { return { - command: `gittensory-mcp login --profile ${context.profileName}`, + command: `gittensory-mcp login --profile ${shellArg(context.profileName ?? "default")}`, reason: "Authenticate the active profile so doctor, plan, preflight, and packet commands can call the API.", }; } @@ -1920,11 +1920,17 @@ function doctorNextCommand(byName, context) { }; } return { - command: `gittensory-mcp preflight --login ${context.login ?? ""} --repo ${context.repoFullName ?? "owner/repo"} --json`, + command: `gittensory-mcp preflight --login ${shellArg(context.login ?? "")} --repo ${shellArg(context.repoFullName ?? "owner/repo")} --json`, reason: "Run branch preflight next; source upload remains disabled.", }; } +function shellArg(value) { + const text = String(value ?? ""); + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(text)) return text; + return `'${text.replace(/'/g, `'"'"'`)}'`; +} + 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 c214fe868d..bed5e066af 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -93,6 +93,27 @@ describe("gittensory-mcp CLI", () => { expect(localScorer?.detail).not.toMatch(join(process.cwd(), "test/fixtures")); }); + it("shell-quotes doctor next command values derived from local repo metadata", async () => { + tempDir = createPacketRepo(); + git(tempDir, "remote", "set-url", "origin", "git@github.com:owner/repo$(touch /tmp/av_pwned).git"); + const url = await startFixtureServer(); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_TOKEN: "session-token", + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSOR_SCORE_PREVIEW_CMD: `node ${join(process.cwd(), "test/fixtures/local-scorer/scorer-success.mjs")}`, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }; + + const payload = JSON.parse(await runAsync(["doctor", "--cwd", tempDir, "--json"], env)) as { nextCommand: { command: string } }; + expect(payload.nextCommand.command).toBe("gittensory-mcp preflight --login JSONbored --repo 'owner/repo$(touch /tmp/av_pwned)' --json"); + expect(payload.nextCommand.command).not.toContain("--repo owner/repo$("); + + const humanOutput = await runAsync(["doctor", "--cwd", tempDir], env); + expect(humanOutput).toContain("gittensory-mcp preflight --login JSONbored --repo 'owner/repo$(touch /tmp/av_pwned)' --json"); + expect(humanOutput).not.toContain("--repo owner/repo$("); + }); + 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();