diff --git a/packages/gittensory-mcp/README.md b/packages/gittensory-mcp/README.md index d85384a320..b49e8d60c0 100644 --- a/packages/gittensory-mcp/README.md +++ b/packages/gittensory-mcp/README.md @@ -33,6 +33,9 @@ gittensory-mcp whoami gittensory-mcp status gittensory-mcp changelog gittensory-mcp doctor +gittensory-mcp profile list +gittensory-mcp profile create work +gittensory-mcp profile switch work gittensory-mcp cache status gittensory-mcp cache clear gittensory-mcp init-client --print codex @@ -70,6 +73,19 @@ gittensory-mcp login --github-token "$(gh auth token)" The wrapper stores a Gittensory session token, not a GitHub token. +The default profile keeps normal single-account usage simple. For multiple identities, use named profiles: + +```sh +gittensory-mcp login --profile personal --github-token "$(gh auth token)" +gittensory-mcp login --profile work --github-token "$WORK_GITHUB_TOKEN" +gittensory-mcp profile list +gittensory-mcp profile switch work +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. + ## 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. @@ -90,6 +106,7 @@ The same capabilities are exposed to MCP clients as: ## Environment - `GITTENSORY_API_URL` +- `GITTENSORY_PROFILE` - `GITTENSORY_CONFIG_PATH` or `GITTENSORY_CONFIG_DIR` - `GITTENSORY_API_TOKEN`, `GITTENSORY_MCP_TOKEN`, or `GITTENSORY_TOKEN` - `GITHUB_TOKEN` for non-interactive login bootstrap diff --git a/packages/gittensory-mcp/bin/gittensory-mcp.js b/packages/gittensory-mcp/bin/gittensory-mcp.js index 2094c404c3..ade603f642 100755 --- a/packages/gittensory-mcp/bin/gittensory-mcp.js +++ b/packages/gittensory-mcp/bin/gittensory-mcp.js @@ -21,6 +21,8 @@ const decisionPackCacheSchemaVersion = 1; const decisionPackCacheMaxEntries = 25; const decisionPackCacheMaxBytes = 512 * 1024; const changelogPath = new URL("../CHANGELOG.md", import.meta.url); +const cliArgs = process.argv.slice(2); +const defaultProfileName = "default"; const configPath = process.env.GITTENSORY_CONFIG_PATH ?? (process.env.GITTENSORY_CONFIG_DIR @@ -29,7 +31,10 @@ const configPath = const cacheDir = process.env.GITTENSORY_CACHE_DIR ?? join(dirname(configPath), "cache"); const decisionPackCacheDir = join(cacheDir, "decision-packs"); const config = loadConfig(); -const configuredApiUrl = typeof config.apiUrl === "string" ? config.apiUrl.replace(/\/+$/, "") : undefined; +const requestedProfileName = cliOptionValue(cliArgs, "profile") ?? process.env.GITTENSORY_PROFILE; +const activeProfileName = selectProfileName(config, requestedProfileName); +const activeProfile = config.profiles?.[activeProfileName] ?? {}; +const configuredApiUrl = typeof activeProfile.apiUrl === "string" ? activeProfile.apiUrl.replace(/\/+$/, "") : typeof config.apiUrl === "string" ? config.apiUrl.replace(/\/+$/, "") : undefined; const apiUrl = (process.env.GITTENSORY_API_URL ?? (configuredApiUrl && !legacyDefaultApiUrls.has(configuredApiUrl) ? configuredApiUrl : defaultApiUrl)).replace(/\/+$/, ""); const ownerRepoShape = { @@ -159,7 +164,6 @@ const agentRunIdShape = { runId: z.string().min(1), }; -const cliArgs = process.argv.slice(2); if (cliArgs[0] && cliArgs[0] !== "--stdio") { await runCli(cliArgs); process.exit(0); @@ -297,8 +301,9 @@ server.registerTool( version: packageVersion, }, hasToken: Boolean(getApiToken()), - authLogin: config.session?.login ?? null, - sessionExpiresAt: config.session?.expiresAt ?? null, + profile: profilePublicState(activeProfileName), + authLogin: activeProfile.session?.login ?? null, + sessionExpiresAt: activeProfile.session?.expiresAt ?? null, sourceUploadDefault: false, sourceUploadSupported: false, git, @@ -485,6 +490,7 @@ async function runCli(args) { const options = parseOptions(args.slice(1)); if (command === "login") return login(options); if (command === "logout") return logout(options); + if (command === "profile" || command === "profiles") return profileCommand(args.slice(1)); if (command === "whoami") return whoami(options); if (command === "status") return status(options); if (command === "changelog") return changelog(options); @@ -710,12 +716,13 @@ function isUnsafePublicPacketText(value) { function printHelp() { process.stdout.write(`Usage: gittensory-mcp --stdio - gittensory-mcp login [--github-token ] [--json] - gittensory-mcp logout [--json] - gittensory-mcp whoami [--json] - gittensory-mcp status [--json] + gittensory-mcp login [--profile name] [--github-token ] [--json] + gittensory-mcp logout [--profile name] [--all] [--json] + gittensory-mcp whoami [--profile name] [--json] + gittensory-mcp status [--profile name] [--json] + gittensory-mcp profile list|create|switch|remove [name] [--json] gittensory-mcp changelog [--json] - gittensory-mcp doctor [--cwd path] [--json] + gittensory-mcp doctor [--profile name] [--cwd path] [--json] gittensory-mcp cache status|clear [--json] gittensory-mcp init-client --print codex|claude|cursor|mcp [--json] gittensory-mcp decision-pack --login [--json] @@ -727,8 +734,9 @@ function printHelp() { gittensory-mcp agent explain [--json] gittensory-mcp agent packet --login [--repo owner/repo] [--base origin/main] [--json] -Environment: + Environment: GITTENSORY_API_URL + GITTENSORY_PROFILE GITTENSORY_CONFIG_PATH or GITTENSORY_CONFIG_DIR GITTENSORY_API_TOKEN, GITTENSORY_MCP_TOKEN, GITTENSORY_TOKEN, or a session from gittensory-mcp login GITHUB_TOKEN for non-interactive login bootstrap @@ -758,6 +766,17 @@ function printAgentHelp() { The agent is copilot-only: it ranks, explains, and drafts public-safe packets. It does not edit code, open PRs, or post comments from the local MCP wrapper. Source upload remains disabled. + `); +} + +function printProfileHelp() { + process.stdout.write(`Usage: + gittensory-mcp profile list [--json] + gittensory-mcp profile create [--json] + gittensory-mcp profile switch [--json] + gittensory-mcp profile remove [--json] + +Use --profile or GITTENSORY_PROFILE to run login, logout, whoami, status, doctor, and MCP API calls with a named local session. `); } @@ -785,10 +804,10 @@ function parseOptions(args) { } async function login(options) { + const profileName = selectedProfileName(options); const githubToken = options.githubToken ?? process.env.GITHUB_TOKEN; const session = githubToken ? await apiFetch("/v1/auth/github/session", { method: "POST", body: JSON.stringify({ githubToken }) }, { auth: false }) : await loginWithDeviceFlow(); - saveConfig({ - ...config, + const nextConfig = upsertProfile(config, profileName, { apiUrl, session: { token: session.token, @@ -797,9 +816,10 @@ async function login(options) { scopes: session.scopes ?? [], }, }); - const payload = { status: "authenticated", login: session.login, apiUrl, expiresAt: session.expiresAt }; + saveConfig(nextConfig); + const payload = { status: "authenticated", profile: profileName, login: session.login, apiUrl, expiresAt: session.expiresAt }; if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - else process.stdout.write(`Authenticated as ${session.login}. Session expires ${session.expiresAt}.\n`); + else process.stdout.write(`Authenticated profile ${profileName} as ${session.login}. Session expires ${session.expiresAt}.\n`); } async function loginWithDeviceFlow() { @@ -818,26 +838,86 @@ async function loginWithDeviceFlow() { } async function logout(options) { - const token = getApiToken(); - let remote = null; - if (token) { + const profileName = selectedProfileName(options); + const all = options.all === true; + const envToken = getEnvApiToken(); + const tokens = all + ? [envToken, ...profileSessions(config).map((entry) => entry.session.token)].filter(Boolean) + : [envToken ?? configuredProfileToken(profileName)].filter(Boolean); + const remote = []; + for (const token of [...new Set(tokens)]) { try { - remote = await apiFetch("/v1/auth/logout", { method: "POST", body: "{}" }); + remote.push(await apiFetch("/v1/auth/logout", { method: "POST", body: "{}" }, { token })); } catch (error) { - remote = { error: error instanceof Error ? error.message : "logout_failed" }; + remote.push({ error: sanitizeDiagnosticText(error instanceof Error ? error.message : "logout_failed") }); } } - if (existsSync(configPath)) rmSync(configPath, { force: true }); + const nextConfig = all ? clearAllProfileSessions(config) : clearProfileSession(config, profileName); + if (hasPersistedConfigState(nextConfig)) saveConfig(nextConfig); + else if (existsSync(configPath)) rmSync(configPath, { force: true }); const decisionPackCache = clearDecisionPackCache(); - const payload = { status: "logged_out", apiUrl, remote, decisionPackCache }; + const payload = { status: "logged_out", profile: all ? "all" : profileName, apiUrl, remote: remote.length > 0 ? remote : null, decisionPackCache }; if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - else process.stdout.write("Logged out.\n"); + else process.stdout.write(all ? "Logged out all profiles.\n" : `Logged out profile ${profileName}.\n`); +} + +function profileCommand(args) { + const subcommand = args[0] ?? "list"; + const options = parseOptions(args.slice(1)); + if (subcommand === "--help" || subcommand === "help") return printProfileHelp(); + if (subcommand === "list" || subcommand === "ls") { + const profiles = profileList(config); + const payload = { activeProfile: activeProfileName, profiles }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else { + process.stdout.write(`Active profile: ${activeProfileName}\n`); + for (const profile of profiles) { + process.stdout.write(`- ${profile.name}${profile.active ? " (active)" : ""}: ${profile.login ?? "not authenticated"}\n`); + } + } + return; + } + + const rawName = args[1] && !args[1].startsWith("--") ? args[1] : options.name ?? options.profile; + if (!rawName) throw new Error(`Usage: gittensory-mcp profile ${subcommand} `); + const profileName = normalizeProfileName(rawName); + + if (subcommand === "create") { + const nextConfig = ensureProfile(config, profileName, { activate: true }); + saveConfig(nextConfig); + const payload = { status: "created", activeProfile: profileName, profile: profilePublicState(profileName, nextConfig) }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(`Created and selected profile ${profileName}.\n`); + return; + } + + if (subcommand === "switch" || subcommand === "use") { + if (!config.profiles?.[profileName]) throw new Error(`Profile ${profileName} does not exist. Run \`gittensory-mcp profile create ${profileName}\` or \`gittensory-mcp login --profile ${profileName}\`.`); + const nextConfig = setActiveProfile(config, profileName); + saveConfig(nextConfig); + const payload = { status: "switched", activeProfile: profileName, profile: profilePublicState(profileName, nextConfig) }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(`Selected profile ${profileName}.\n`); + return; + } + + if (subcommand === "remove" || subcommand === "rm" || subcommand === "delete") { + const nextConfig = removeProfile(config, profileName); + if (hasPersistedConfigState(nextConfig)) saveConfig(nextConfig); + else if (existsSync(configPath)) rmSync(configPath, { force: true }); + const payload = { status: "removed", removedProfile: profileName, activeProfile: nextConfig.activeProfile ?? defaultProfileName }; + if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + else process.stdout.write(`Removed profile ${profileName}.\n`); + return; + } + + throw new Error(`Unknown profile command: ${subcommand}`); } async function whoami(options) { - const payload = await apiGet("/v1/auth/session"); + const payload = { ...(await apiGet("/v1/auth/session")), profile: activeProfileName }; if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - else process.stdout.write(`${payload.login}\n`); + else process.stdout.write(activeProfileName === defaultProfileName ? `${payload.login}\n` : `${payload.login} (profile ${activeProfileName})\n`); } async function status(options) { @@ -866,7 +946,8 @@ async function status(options) { compatibility: compatibility.report, api: health, auth, - config: { configured: existsSync(configPath) }, + profile: profilePublicState(activeProfileName), + config: { configured: existsSync(configPath), activeProfile: activeProfileName, profileCount: profileList(config).length }, decisionPackCache, sourceUploadDefault: false, sourceUploadSupported: false, @@ -875,6 +956,7 @@ async function status(options) { else { process.stdout.write(`${packageName}: ${packageVersion}${pkg.latestVersion ? ` (latest ${pkg.latestVersion})` : ""}\n`); process.stdout.write(`API: ${apiUrl}\n`); + process.stdout.write(`Profile: ${activeProfileName}\n`); process.stdout.write(`API health: ${health?.status ?? "unknown"}\n`); process.stdout.write(`Auth: ${auth.status}${auth.login ? ` (${auth.login})` : ""}\n`); process.stdout.write(`Decision-pack cache: ${decisionPackCache.entries} entr${decisionPackCache.entries === 1 ? "y" : "ies"}\n`); @@ -962,13 +1044,13 @@ async function doctor(options) { const token = getApiToken(); if (!token) { - add("auth", "fail", "No Gittensory API/session token is configured.", "Run `gittensory-mcp login`."); + add("auth", "fail", `No Gittensory API/session token is configured for profile ${activeProfileName}.`, `Run \`gittensory-mcp login --profile ${activeProfileName}\`.`); } else { try { const session = await apiGet("/v1/auth/session"); - add("auth", "pass", `Authenticated as ${session.login}; session expires ${session.expiresAt}.`); + add("auth", "pass", `Profile ${activeProfileName} authenticated as ${session.login}; session expires ${session.expiresAt}.`); } catch (error) { - add("auth", "warn", `A token is configured 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`."); + 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`."); } } @@ -991,7 +1073,7 @@ async function doctor(options) { cwd: options.cwd ?? process.cwd(), baseRef: options.base, repoFullName: options.repo, - login: options.login ?? config.session?.login ?? "local", + login: options.login ?? activeProfile.session?.login ?? "local", }); add("git_metadata", "pass", `${metadata.repoFullName} on ${metadata.branchName}; ${metadata.changedFiles.length} changed file(s).`); } catch (error) { @@ -1029,7 +1111,8 @@ async function doctor(options) { const payload = { status: checks.some((check) => check.status === "fail") ? "needs_attention" : checks.some((check) => check.status === "warn") ? "warnings" : "ok", apiUrl, - config: { configured: existsSync(configPath) }, + profile: profilePublicState(activeProfileName), + config: { configured: existsSync(configPath), activeProfile: activeProfileName, profileCount: profileList(config).length }, decisionPackCache, sourceUploadSupported: false, checks, @@ -1037,6 +1120,7 @@ async function doctor(options) { 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`); @@ -1065,7 +1149,126 @@ function initClient(options) { } function getApiToken() { - return process.env.GITTENSORY_API_TOKEN ?? process.env.GITTENSORY_TOKEN ?? process.env.GITTENSORY_MCP_TOKEN ?? config.session?.token; + return getEnvApiToken() ?? configuredProfileToken(activeProfileName); +} + +function getEnvApiToken() { + return process.env.GITTENSORY_API_TOKEN ?? process.env.GITTENSORY_TOKEN ?? process.env.GITTENSORY_MCP_TOKEN; +} + +function selectedProfileName(options = {}) { + return normalizeProfileName(options.profile ?? activeProfileName); +} + +function configuredProfileToken(profileName, currentConfig = config) { + return currentConfig.profiles?.[profileName]?.session?.token; +} + +function profileSessions(currentConfig = config) { + return Object.entries(currentConfig.profiles ?? {}) + .flatMap(([name, profile]) => (profile?.session?.token ? [{ name, session: profile.session }] : [])); +} + +function profilePublicState(profileName, currentConfig = config) { + const profile = currentConfig.profiles?.[profileName]; + const hasEnvToken = Boolean(getEnvApiToken()); + return { + name: profileName, + active: profileName === (currentConfig.activeProfile ?? defaultProfileName), + configured: Boolean(profile), + authenticated: Boolean(profile?.session?.token), + login: profile?.session?.login ?? null, + expiresAt: profile?.session?.expiresAt ?? null, + tokenSource: hasEnvToken ? "environment" : profile?.session?.token ? "profile" : "none", + apiUrl: profile?.apiUrl ?? currentConfig.apiUrl ?? null, + }; +} + +function profileList(currentConfig = config) { + const names = new Set([defaultProfileName, currentConfig.activeProfile ?? defaultProfileName, ...Object.keys(currentConfig.profiles ?? {})]); + return [...names].sort((left, right) => (left === currentConfig.activeProfile ? -1 : right === currentConfig.activeProfile ? 1 : left.localeCompare(right))).map((name) => profilePublicState(name, currentConfig)); +} + +function selectProfileName(currentConfig, requestedName) { + const requested = requestedName ? normalizeProfileName(requestedName) : undefined; + if (requested) return requested; + const configured = currentConfig?.activeProfile ? normalizeProfileName(currentConfig.activeProfile) : defaultProfileName; + if (currentConfig?.profiles?.[configured]) return configured; + return currentConfig?.profiles?.[defaultProfileName] || configured === defaultProfileName ? defaultProfileName : configured; +} + +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."); + return name; +} + +function cliOptionValue(args, optionName) { + const dashed = `--${optionName.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; + for (let index = 0; index < args.length; index += 1) { + const value = args[index]; + if (value === dashed) { + const next = args[index + 1]; + return next && !next.startsWith("--") ? next : undefined; + } + if (value?.startsWith(`${dashed}=`)) return value.slice(dashed.length + 1); + } + return undefined; +} + +function upsertProfile(currentConfig, profileName, patch) { + const now = new Date().toISOString(); + const existing = currentConfig.profiles?.[profileName] ?? {}; + const profiles = { + ...(currentConfig.profiles ?? {}), + [profileName]: stripUndefined({ + ...existing, + apiUrl: patch.apiUrl ?? existing.apiUrl, + session: patch.session ?? existing.session, + createdAt: existing.createdAt ?? now, + updatedAt: now, + }), + }; + return normalizeConfig({ ...currentConfig, apiUrl: patch.apiUrl ?? currentConfig.apiUrl, activeProfile: profileName, profiles }); +} + +function ensureProfile(currentConfig, profileName, options = {}) { + const existing = currentConfig.profiles?.[profileName]; + const nextConfig = existing ? currentConfig : upsertProfile(currentConfig, profileName, {}); + return options.activate ? setActiveProfile(nextConfig, profileName) : nextConfig; +} + +function setActiveProfile(currentConfig, profileName) { + return normalizeConfig({ ...currentConfig, activeProfile: profileName }); +} + +function clearProfileSession(currentConfig, profileName) { + const existing = currentConfig.profiles?.[profileName]; + if (!existing) return currentConfig; + const profiles = { + ...(currentConfig.profiles ?? {}), + [profileName]: stripUndefined({ ...existing, session: undefined, updatedAt: new Date().toISOString() }), + }; + return normalizeConfig({ ...currentConfig, profiles }); +} + +function clearAllProfileSessions(currentConfig) { + const profiles = Object.fromEntries( + Object.entries(currentConfig.profiles ?? {}).map(([name, profile]) => [name, stripUndefined({ ...profile, session: undefined, updatedAt: new Date().toISOString() })]), + ); + return normalizeConfig({ ...currentConfig, profiles }); +} + +function removeProfile(currentConfig, profileName) { + const profiles = { ...(currentConfig.profiles ?? {}) }; + delete profiles[profileName]; + const remaining = Object.keys(profiles); + const activeProfile = currentConfig.activeProfile === profileName ? (profiles[defaultProfileName] ? defaultProfileName : remaining[0] ?? defaultProfileName) : currentConfig.activeProfile; + return normalizeConfig({ ...currentConfig, activeProfile, profiles }); +} + +function hasPersistedConfigState(currentConfig) { + return Boolean(currentConfig.apiUrl || Object.keys(currentConfig.profiles ?? {}).length > 0); } function validationFromOptions(options) { @@ -1483,6 +1686,7 @@ function sanitizeDiagnosticText(value, extraPaths = []) { process.env.GITTENSORY_MCP_TOKEN, process.env.GITTENSORY_TOKEN, config.session?.token, + ...profileSessions(config).map((entry) => entry.session.token), ].filter((candidate) => typeof candidate === "string" && candidate.length > 0); for (const token of sensitiveValues) { text = text.split(token).join("[redacted]"); @@ -1504,7 +1708,7 @@ function sanitizeDiagnosticText(value, extraPaths = []) { function loadConfig() { if (!existsSync(configPath)) return {}; try { - return JSON.parse(readFileSync(configPath, "utf8")); + return normalizeConfig(JSON.parse(readFileSync(configPath, "utf8"))); } catch { return {}; } @@ -1512,7 +1716,72 @@ function loadConfig() { function saveConfig(nextConfig) { mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 }); - writeFileSync(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`, { mode: 0o600 }); + writeFileSync(configPath, `${JSON.stringify(configForPersistence(nextConfig), null, 2)}\n`, { mode: 0o600 }); +} + +function normalizeConfig(rawConfig) { + const raw = rawConfig && typeof rawConfig === "object" && !Array.isArray(rawConfig) ? rawConfig : {}; + const profiles = {}; + const rawProfiles = raw.profiles && typeof raw.profiles === "object" && !Array.isArray(raw.profiles) ? raw.profiles : {}; + for (const [rawName, rawProfile] of Object.entries(rawProfiles)) { + try { + const name = normalizeProfileName(rawName); + const profile = normalizeProfile(rawProfile); + if (profile) profiles[name] = profile; + } catch { + // Ignore malformed profile names in local config instead of leaking paths or tokens. + } + } + if (raw.session?.token && !profiles[defaultProfileName]) { + profiles[defaultProfileName] = normalizeProfile({ + apiUrl: raw.apiUrl, + session: raw.session, + }); + } + let activeProfile = defaultProfileName; + try { + activeProfile = selectProfileName({ ...raw, profiles }, raw.activeProfile); + } catch { + activeProfile = defaultProfileName; + } + return stripUndefined({ + ...raw, + activeProfile, + profiles, + session: profiles[defaultProfileName]?.session, + }); +} + +function normalizeProfile(rawProfile) { + const raw = rawProfile && typeof rawProfile === "object" && !Array.isArray(rawProfile) ? rawProfile : {}; + const session = normalizeSession(raw.session); + return stripUndefined({ + apiUrl: typeof raw.apiUrl === "string" ? raw.apiUrl.replace(/\/+$/, "") : undefined, + session, + createdAt: typeof raw.createdAt === "string" ? raw.createdAt : undefined, + updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : undefined, + }); +} + +function normalizeSession(rawSession) { + const raw = rawSession && typeof rawSession === "object" && !Array.isArray(rawSession) ? rawSession : {}; + if (typeof raw.token !== "string" || raw.token.length === 0) return undefined; + return stripUndefined({ + token: raw.token, + login: typeof raw.login === "string" ? raw.login : undefined, + expiresAt: typeof raw.expiresAt === "string" ? raw.expiresAt : undefined, + scopes: Array.isArray(raw.scopes) ? raw.scopes.filter((scope) => typeof scope === "string") : [], + }); +} + +function configForPersistence(nextConfig) { + const normalized = normalizeConfig(nextConfig); + return stripUndefined({ + apiUrl: normalized.apiUrl, + activeProfile: normalized.activeProfile, + profiles: normalized.profiles, + session: normalized.profiles?.[defaultProfileName]?.session, + }); } function sleep(ms) { @@ -1528,7 +1797,7 @@ async function apiPost(path, body) { } async function apiFetch(path, init, options = {}) { - const token = getApiToken(); + const token = options.token ?? getApiToken(); if (options.auth !== false && !token) { const error = new Error("Run `gittensory-mcp login`, or set GITTENSORY_API_TOKEN, GITTENSORY_MCP_TOKEN, or GITTENSORY_TOKEN before starting the MCP wrapper."); error.status = 401; diff --git a/test/unit/mcp-cli.test.ts b/test/unit/mcp-cli.test.ts index 2adee019b1..2e101006b6 100644 --- a/test/unit/mcp-cli.test.ts +++ b/test/unit/mcp-cli.test.ts @@ -302,6 +302,105 @@ describe("gittensory-mcp CLI", () => { expect(statusOutput).toContain("npm install -g @jsonbored/gittensory-mcp@latest"); }); + it("stores, switches, and reports named MCP profiles without mixing sessions", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + const url = await startFixtureServer({ + onApiRequest: (request) => requests.push({ url: request.url, authorization: request.headers.authorization }), + }); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }; + + const firstLogin = JSON.parse(await runAsync(["login", "--profile", "JSONbored", "--github-token", "github-jsonbored", "--json"], env)) as { profile: string; login: string }; + const secondLogin = JSON.parse(await runAsync(["login", "--profile", "Okto", "--github-token", "github-okto", "--json"], env)) as { profile: string; login: string }; + const list = JSON.parse(await runAsync(["profile", "list", "--json"], env)) as { activeProfile: string; profiles: Array<{ name: string; login: string | null; authenticated: boolean }> }; + const firstWhoami = JSON.parse(await runAsync(["whoami", "--profile", "jsonbored", "--json"], env)) as { profile: string; login: string }; + const secondWhoami = JSON.parse(await runAsync(["whoami", "--profile", "okto", "--json"], env)) as { profile: string; login: string }; + const switched = JSON.parse(await runAsync(["profile", "switch", "jsonbored", "--json"], env)) as { activeProfile: string }; + const activeWhoami = JSON.parse(await runAsync(["whoami", "--json"], env)) as { profile: string; login: string }; + + expect(firstLogin).toMatchObject({ profile: "jsonbored", login: "JSONbored" }); + expect(secondLogin).toMatchObject({ profile: "okto", login: "oktofeesh1" }); + expect(list.activeProfile).toBe("okto"); + expect(list.profiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "jsonbored", login: "JSONbored", authenticated: true }), + expect.objectContaining({ name: "okto", login: "oktofeesh1", authenticated: true }), + ]), + ); + expect(firstWhoami).toMatchObject({ profile: "jsonbored", login: "JSONbored" }); + expect(secondWhoami).toMatchObject({ profile: "okto", login: "oktofeesh1" }); + expect(switched.activeProfile).toBe("jsonbored"); + expect(activeWhoami).toMatchObject({ profile: "jsonbored", login: "JSONbored" }); + expect(requests).toEqual( + expect.arrayContaining([ + expect.objectContaining({ url: "/v1/auth/session", authorization: "Bearer session-jsonbored" }), + expect.objectContaining({ url: "/v1/auth/session", authorization: "Bearer session-okto" }), + ]), + ); + expect(JSON.stringify(list)).not.toMatch(/session-jsonbored|session-okto|github-jsonbored|github-okto|gittensory-cli-/); + }); + + it("keeps environment tokens ahead of active profile sessions", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + const url = await startFixtureServer({ + onApiRequest: (request) => requests.push({ url: request.url, authorization: request.headers.authorization }), + }); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }; + + await runAsync(["login", "--profile", "jsonbored", "--github-token", "github-jsonbored", "--json"], env); + await runAsync(["profile", "switch", "jsonbored", "--json"], env); + const whoami = JSON.parse(await runAsync(["whoami", "--json"], { ...env, GITTENSORY_TOKEN: "session-okto" })) as { profile: string; login: string }; + const status = JSON.parse(await runAsync(["status", "--json"], { ...env, GITTENSORY_TOKEN: "session-okto" })) as { profile: { tokenSource: string }; auth: { login: string } }; + + expect(whoami).toMatchObject({ profile: "jsonbored", login: "oktofeesh1" }); + expect(status).toMatchObject({ auth: { login: "oktofeesh1" }, profile: { tokenSource: "environment" } }); + expect(requests).toEqual(expect.arrayContaining([expect.objectContaining({ url: "/v1/auth/session", authorization: "Bearer session-okto" })])); + }); + + it("logs out only the selected profile and reports missing profiles safely", async () => { + tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); + const requests: Array<{ url: string | undefined; authorization: string | undefined }> = []; + const url = await startFixtureServer({ + onApiRequest: (request) => requests.push({ url: request.url, authorization: request.headers.authorization }), + }); + const env = { + GITTENSORY_API_URL: url, + GITTENSORY_CONFIG_DIR: tempDir, + GITTENSORY_SKIP_NPM_VERSION_CHECK: "true", + }; + + await runAsync(["login", "--profile", "alpha", "--github-token", "github-jsonbored", "--json"], env); + await runAsync(["login", "--profile", "beta", "--github-token", "github-okto", "--json"], env); + const logout = JSON.parse(await runAsync(["logout", "--profile", "alpha", "--json"], env)) as { profile: string; status: string }; + const list = JSON.parse(await runAsync(["profile", "list", "--json"], env)) as { profiles: Array<{ name: string; authenticated: boolean; login: string | null }> }; + const betaWhoami = JSON.parse(await runAsync(["whoami", "--profile", "beta", "--json"], env)) as { profile: string; login: string }; + const missingStatus = JSON.parse(await runAsync(["status", "--profile", "missing", "--json"], env)) as { auth: { status: string }; profile: { name: string; configured: boolean; authenticated: boolean } }; + const doctor = JSON.parse(await runAsync(["doctor", "--profile", "missing", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], env)) as { profile: { name: string; configured: boolean }; checks: Array<{ name: string; status: string; detail: string }> }; + + expect(logout).toMatchObject({ status: "logged_out", profile: "alpha" }); + expect(list.profiles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: "alpha", authenticated: false, login: null }), + expect.objectContaining({ name: "beta", authenticated: true, login: "oktofeesh1" }), + ]), + ); + expect(betaWhoami).toMatchObject({ profile: "beta", login: "oktofeesh1" }); + expect(missingStatus).toMatchObject({ auth: { status: "unauthenticated" }, profile: { name: "missing", configured: false, authenticated: false } }); + expect(doctor.profile).toMatchObject({ name: "missing", configured: false }); + expect(doctor.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "auth", status: "fail" })])); + expect(requests).toEqual(expect.arrayContaining([expect.objectContaining({ url: "/v1/auth/logout", authorization: "Bearer session-jsonbored" })])); + expect(JSON.stringify({ logout, list, missingStatus, doctor })).not.toMatch(/session-jsonbored|session-okto|github-jsonbored|github-okto|gittensory-cli-/); + }); + it("reports package status and prints the packaged changelog", async () => { tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-")); const url = await startFixtureServer(); @@ -905,10 +1004,37 @@ async function startFixtureServer( response.end(JSON.stringify({ status: "ok", service: "gittensory-api", ...(options.minMcpVersion ? { minMcpVersion: options.minMcpVersion } : {}) })); return; } + if (request.url === "/v1/auth/github/session" && request.method === "POST") { + const body = (await readJsonRequest(request)) as { githubToken?: string }; + const sessions: Record = { + "github-jsonbored": { token: "session-jsonbored", login: "JSONbored" }, + "github-okto": { token: "session-okto", login: "oktofeesh1" }, + }; + const session = body.githubToken ? sessions[body.githubToken] : null; + if (!session) { + response.statusCode = 401; + response.end(JSON.stringify({ error: "github_session_create_failed" })); + return; + } + response.end(JSON.stringify({ status: "authenticated", token: session.token, login: session.login, expiresAt: "2026-06-02T00:00:00.000Z", scopes: ["read:user"] })); + return; + } if (request.url === "/v1/auth/session" && request.headers.authorization === "Bearer session-token") { response.end(JSON.stringify({ status: "authenticated", login: "JSONbored", expiresAt: "2026-06-02T00:00:00.000Z", scopes: ["read:user"] })); return; } + if (request.url === "/v1/auth/session" && request.headers.authorization === "Bearer session-jsonbored") { + response.end(JSON.stringify({ status: "authenticated", login: "JSONbored", expiresAt: "2026-06-02T00:00:00.000Z", scopes: ["read:user"] })); + return; + } + if (request.url === "/v1/auth/session" && request.headers.authorization === "Bearer session-okto") { + response.end(JSON.stringify({ status: "authenticated", login: "oktofeesh1", expiresAt: "2026-06-02T00:00:00.000Z", scopes: ["read:user"] })); + return; + } + if (request.url === "/v1/auth/logout" && request.method === "POST") { + response.end(JSON.stringify({ status: "logged_out" })); + return; + } if (request.url === "/v1/contributors/JSONbored/decision-pack" && request.method === "GET") { if (options.decisionPackStatus && options.decisionPackStatus >= 400) { response.statusCode = options.decisionPackStatus;