From 0066f89b360edb60bd9ae8896a801e0b115ffcce Mon Sep 17 00:00:00 2001 From: clawdeeo Date: Wed, 13 May 2026 23:16:12 +0000 Subject: [PATCH] feat: add profile management commands --- src/cli/index.ts | 2 + src/commands/profile.ts | 42 ++++++++ src/core/config.ts | 26 ++++- src/core/constants.ts | 1 + src/services/profile.ts | 225 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 src/commands/profile.ts create mode 100644 src/services/profile.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index e807beb..7566958 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -10,6 +10,7 @@ import configCommand from "@/commands/config"; import mentionsCommand from "@/commands/mentions"; import activityCommand from "@/commands/activity"; import notificationsCommand from "@/commands/notifications"; +import profileCommand from "@/commands/profile"; import { GhitgudError } from "@/core/errors"; const NAME = "ghitgud"; @@ -24,6 +25,7 @@ mentionsCommand.register(program); pingCommand.register(program); labelsCommand.register(program); configCommand.register(program); +profileCommand.register(program); program.addHelpText("before", ascii); program.exitOverride(); diff --git a/src/commands/profile.ts b/src/commands/profile.ts new file mode 100644 index 0000000..1f3472b --- /dev/null +++ b/src/commands/profile.ts @@ -0,0 +1,42 @@ +import { Command } from "commander"; +import profileService from "@/services/profile"; + +const register = (program: Command) => { + const profile = program + .command("profile") + .description("Manage GitHub account profiles."); + + profile + .command("list") + .description("Show all configured profiles.") + .action(() => void profileService.list()); + + profile + .command("switch") + .description("Switch active account profile.") + .arguments("") + .action((name: string) => void profileService.switchProfile(name)); + + profile + .command("add") + .description("Add a new profile.") + .requiredOption("--name ", "Profile name") + .requiredOption("--token ", "GitHub personal access token") + .option("--repo ", "Default repository (owner/repo)") + .action((options) => { + profileService.add(options.name, options.token, options.repo); + }); + + profile + .command("remove") + .description("Remove a profile.") + .arguments("") + .action((name: string) => void profileService.remove(name)); + + profile + .command("detect") + .description("Auto-detect account from current repo remote.") + .action(() => void profileService.detect()); +}; + +export default { register }; diff --git a/src/core/config.ts b/src/core/config.ts index be9ac28..c25b653 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -10,6 +10,7 @@ import { ERROR_NO_TOKEN, CREDENTIALS_PATH, } from "@/core/constants"; +import profileService from "@/services/profile"; function readCredentialsFile(): Record | null { if (!fs.existsSync(CREDENTIALS_PATH)) return null; @@ -24,12 +25,25 @@ function resolve(key: string, envVar: string): string { const credentials = readCredentialsFile(); if (credentials && credentials[key]) return credentials[key]; + const active = profileService.getActiveProfile(); + if (active) { + if (key === "token") return active.token; + if (key === "repo" && active.repo) return active.repo; + } + throw new ConfigError(key === "repo" ? ERROR_NO_REPO : ERROR_NO_TOKEN); } function read(key: string): string | null { const credentials = readCredentialsFile(); if (credentials && credentials[key]) return credentials[key]; + + const active = profileService.getActiveProfile(); + if (active) { + if (key === "token") return active.token; + if (key === "repo" && active.repo) return active.repo; + } + return null; } @@ -44,7 +58,17 @@ function has(key: string): boolean { } const credentials = readCredentialsFile(); - return !!credentials?.[key]; + if (credentials?.[key]) { + return true; + } + + const active = profileService.getActiveProfile(); + if (active) { + if (key === "token") return true; + if (key === "repo" && active.repo) return true; + } + + return false; } function write(key: string, value: string): void { diff --git a/src/core/constants.ts b/src/core/constants.ts index 2f4a4d6..d502df4 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -3,6 +3,7 @@ import path from "path"; export const GHITGUD_FOLDER = path.join(os.homedir(), ".config", "ghitgud"); export const CREDENTIALS_FILE = "credentials.json"; +export const PROFILES_FILE = "profiles.json"; export const METADATA_FILE = "labels.json"; export const ENCODING = "utf8"; diff --git a/src/services/profile.ts b/src/services/profile.ts new file mode 100644 index 0000000..0f0aec9 --- /dev/null +++ b/src/services/profile.ts @@ -0,0 +1,225 @@ +import fs from "fs"; +import { execSync } from "child_process"; +import path from "path"; + +import logger from "@/core/logger"; +import { GhitgudError } from "@/core/errors"; + +import { + ENCODING, + GHITGUD_FOLDER, + PROFILES_FILE, + CREDENTIALS_PATH, +} from "@/core/constants"; + +export interface Profile { + token: string; + repo?: string; + createdAt: string; +} + +export interface ProfilesData { + profiles: Record; + active: string | null; +} + +const PROFILES_PATH = path.join(GHITGUD_FOLDER, PROFILES_FILE); + +function ensureProfilesDir(): void { + if (!fs.existsSync(GHITGUD_FOLDER)) { + fs.mkdirSync(GHITGUD_FOLDER, { recursive: true }); + } +} + +function loadProfiles(): ProfilesData { + if (!fs.existsSync(PROFILES_PATH)) { + return { profiles: {}, active: null }; + } + + const data = fs.readFileSync(PROFILES_PATH, ENCODING); + const parsed = JSON.parse(data); + + return { + profiles: parsed.profiles || {}, + active: parsed.active || null, + }; +} + +function saveProfiles(data: ProfilesData): void { + ensureProfilesDir(); + fs.writeFileSync( + PROFILES_PATH, + JSON.stringify(data, null, 2), + ENCODING, + ); +} + +function writeCredentials(token: string, repo?: string): void { + ensureProfilesDir(); + const credentials: Record = {}; + credentials.token = token; + if (repo) credentials.repo = repo; + + fs.writeFileSync( + CREDENTIALS_PATH, + JSON.stringify(credentials, null, 2), + ENCODING, + ); +} + +function list() { + const data = loadProfiles(); + const names = Object.keys(data.profiles); + + if (names.length === 0) { + logger.info("No profiles configured."); + return { success: true, profiles: [] }; + } + + console.log(); + const rows = names.map((name) => ({ + name, + active: data.active === name ? "*" : "", + repo: data.profiles[name].repo || "(not set)", + createdAt: data.profiles[name].createdAt, + })); + + console.table(rows); + return { success: true, profiles: data.profiles, active: data.active }; +} + +function add(name: string, token: string, repo?: string) { + const data = loadProfiles(); + + if (data.profiles[name]) { + throw new GhitgudError(`Profile "${name}" already exists.`); + } + + data.profiles[name] = { + token, + repo, + createdAt: new Date().toISOString(), + }; + + if (!data.active) { + data.active = name; + writeCredentials(token, repo); + logger.success(`Profile "${name}" added and activated.`); + } else { + logger.success(`Profile "${name}" added.`); + } + + saveProfiles(data); + return { success: true }; +} + +function remove(name: string) { + const data = loadProfiles(); + + if (!data.profiles[name]) { + throw new GhitgudError(`Profile "${name}" does not exist.`); + } + + delete data.profiles[name]; + + if (data.active === name) { + data.active = null; + const remaining = Object.keys(data.profiles); + + if (remaining.length > 0) { + data.active = remaining[0]; + const next = data.profiles[remaining[0]]; + writeCredentials(next.token, next.repo); + logger.success( + `Profile "${name}" removed. Switched to "${remaining[0]}".`, + ); + } else { + if (fs.existsSync(CREDENTIALS_PATH)) { + fs.unlinkSync(CREDENTIALS_PATH); + } + logger.success(`Profile "${name}" removed. No active profile set.`); + } + } else { + logger.success(`Profile "${name}" removed.`); + } + + saveProfiles(data); + return { success: true }; +} + +function switchProfile(name: string) { + const data = loadProfiles(); + + if (!data.profiles[name]) { + throw new GhitgudError(`Profile "${name}" does not exist.`); + } + + data.active = name; + const profile = data.profiles[name]; + writeCredentials(profile.token, profile.repo); + saveProfiles(data); + + logger.success(`Switched to profile "${name}".`); + return { success: true, profile }; +} + +function detect() { + const data = loadProfiles(); + + let remoteUrl: string; + try { + remoteUrl = execSync("git remote get-url origin", { + encoding: ENCODING, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch { + throw new GhitgudError( + "No git remote found. Run this inside a git repository.", + ); + } + + if (!remoteUrl) { + throw new GhitgudError("No git remote found."); + } + + const match = remoteUrl.match( + /github\.com[:/](.+?)\/(.+?)(?:\.git)?$/, + ); + + if (!match) { + throw new GhitgudError("Remote does not point to GitHub."); + } + + const owner = match[1]; + const repo = match[2]; + const fullRepo = `${owner}/${repo}`; + + const matching = Object.entries(data.profiles).find(([, profile]) => { + return profile.repo === fullRepo; + }); + + if (matching) { + logger.success(`Detected profile "${matching[0]}" for ${fullRepo}.`); + return { success: true, profile: matching[0], repo: fullRepo }; + } + + logger.info(`No profile found for ${fullRepo}.`); + return { success: true, profile: null, repo: fullRepo }; +} + +function getActiveProfile(): Profile | null { + const data = loadProfiles(); + if (!data.active || !data.profiles[data.active]) return null; + return data.profiles[data.active]; +} + +export default { + list, + add, + remove, + switchProfile, + detect, + getActiveProfile, + loadProfiles, + saveProfiles, +};