Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand Down
42 changes: 42 additions & 0 deletions src/commands/profile.ts
Original file line number Diff line number Diff line change
@@ -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("<name>")
.action((name: string) => void profileService.switchProfile(name));

profile
.command("add")
.description("Add a new profile.")
.requiredOption("--name <name>", "Profile name")
.requiredOption("--token <token>", "GitHub personal access token")
.option("--repo <repo>", "Default repository (owner/repo)")
.action((options) => {
profileService.add(options.name, options.token, options.repo);
});

profile
.command("remove")
.description("Remove a profile.")
.arguments("<name>")
.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 };
26 changes: 25 additions & 1 deletion src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ERROR_NO_TOKEN,
CREDENTIALS_PATH,
} from "@/core/constants";
import profileService from "@/services/profile";

function readCredentialsFile(): Record<string, string> | null {
if (!fs.existsSync(CREDENTIALS_PATH)) return null;
Expand All @@ -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;
}

Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
225 changes: 225 additions & 0 deletions src/services/profile.ts
Original file line number Diff line number Diff line change
@@ -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<string, Profile>;
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<string, string> = {};
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,
};
Loading