diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index b49e8d60..826af111 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -30,6 +30,8 @@ npm link --workspace @jsonbored/gittensory-mcp gittensory-mcp login gittensory-mcp logout gittensory-mcp whoami +gittensory-mcp config +gittensory-mcp config --json gittensory-mcp status gittensory-mcp changelog gittensory-mcp doctor @@ -84,7 +86,9 @@ gittensory-mcp whoami gittensory-mcp logout --profile work ``` -Use `--profile ` on `login`, `logout`, `whoami`, `status`, and `doctor`, or set `GITTENSORY_PROFILE`. `logout` only clears the selected local profile unless `--all` is passed. Profile output redacts session tokens and local config paths. +Use `--profile ` on `login`, `logout`, `whoami`, `config`, `status`, and `doctor`, or set `GITTENSORY_PROFILE`. `logout` only clears the selected local profile unless `--all` is passed. Profile output redacts session tokens and local config paths. + +`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, and whether a token is configured and where it came from. It never prints token values or local absolute paths. Add `--json` for machine-readable output. ## Base-Agent Mode diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 8aa9d61d..3750e754 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -492,6 +492,7 @@ async function runCli(args) { if (command === "logout") return logout(options); if (command === "profile" || command === "profiles") return profileCommand(args.slice(1)); if (command === "whoami") return whoami(options); + if (command === "config") return configCommand(options); if (command === "status") return status(options); if (command === "changelog") return changelog(options); if (command === "doctor") return doctor(options); @@ -725,6 +726,7 @@ function printHelp() { gittensory-mcp login [--profile name] [--github-token ] [--json] gittensory-mcp logout [--profile name] [--all] [--json] gittensory-mcp whoami [--profile name] [--json] + gittensory-mcp config [--profile name] [--json] gittensory-mcp status [--profile name] [--json] gittensory-mcp profile list|create|switch|remove [name] [--json] gittensory-mcp changelog [--json] @@ -1203,6 +1205,58 @@ function selectProfileName(currentConfig, requestedName) { return currentConfig?.profiles?.[defaultProfileName] || configured === defaultProfileName ? defaultProfileName : configured; } +function resolvedApiUrlSource() { + if (process.env.GITTENSORY_API_URL) return "environment"; + const profileApiUrl = typeof activeProfile.apiUrl === "string" ? activeProfile.apiUrl.replace(/\/+$/, "") : undefined; + if (profileApiUrl && !legacyDefaultApiUrls.has(profileApiUrl)) return "profile"; + const globalApiUrl = typeof config.apiUrl === "string" ? config.apiUrl.replace(/\/+$/, "") : undefined; + if (globalApiUrl && !legacyDefaultApiUrls.has(globalApiUrl)) return "config"; + return "default"; +} + +function resolvedConfigPathSource() { + if (process.env.GITTENSORY_CONFIG_PATH) return "GITTENSORY_CONFIG_PATH"; + if (process.env.GITTENSORY_CONFIG_DIR) return "GITTENSORY_CONFIG_DIR"; + if (process.env.XDG_CONFIG_HOME) return "XDG_CONFIG_HOME"; + return "default"; +} + +function resolvedTokenSource() { + if (getEnvApiToken()) return "environment"; + if (configuredProfileToken(activeProfileName)) return "profile"; + return "none"; +} + +// Report the resolved effective configuration and where each value came from, without leaking +// local absolute paths or token values. Distinct from `status` (health/version), `doctor` +// (diagnostic checks), and `whoami` (session identity): this answers "what config is in effect +// and which source supplied it?". +function configCommand(options) { + const payload = { + apiUrl, + apiUrlSource: resolvedApiUrlSource(), + activeProfile: activeProfileName, + profileCount: profileList(config).length, + configured: existsSync(configPath), + configPathSource: resolvedConfigPathSource(), + cacheDirSource: process.env.GITTENSORY_CACHE_DIR ? "GITTENSORY_CACHE_DIR" : "default", + tokenConfigured: Boolean(getApiToken()), + tokenSource: resolvedTokenSource(), + sourceUpload: { default: false, supported: false }, + profile: profilePublicState(activeProfileName), + }; + if (options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return; + } + process.stdout.write(`API URL: ${payload.apiUrl} (${payload.apiUrlSource})\n`); + process.stdout.write(`Active profile: ${payload.activeProfile} (${payload.profileCount} configured)\n`); + process.stdout.write(`Config file: ${payload.configured ? "present" : "absent"} (location: ${payload.configPathSource})\n`); + process.stdout.write(`Cache dir: ${payload.cacheDirSource}\n`); + process.stdout.write(`Token: ${payload.tokenConfigured ? `configured (${payload.tokenSource})` : "not configured"}\n`); + process.stdout.write("Source upload: disabled (unsupported)\n"); +} + function normalizeProfileName(value) { const name = String(value ?? defaultProfileName).trim().toLowerCase(); if (!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(name)) throw new Error("Profile names must be 1-64 characters and use letters, numbers, dots, dashes, or underscores."); diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index f6aae277..452f994a 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -900,6 +900,105 @@ describe("gittensory-mcp CLI", () => { it("rejects unsupported client snippets", () => { expect(() => run(["init-client", "--print", "other"])).toThrow(/Unsupported client/); }); + + 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; 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, supported: false }); + }); + + it("attributes config values to environment overrides without leaking secrets", () => { + const secretDir = mkdtempSync(join(tmpdir(), "gittensory-config-secret-")); + 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, + }); + 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("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 }); + } + }); + + 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 }); + } + }); }); function run(args: string[], env: Record = {}) {