@relbns/github-cloner
Version:
Github Cloner is a CLI tool for cloning Github repositories with interactive lists in the terminal. the tool lists your repositories and sorts them by owners (users and organizations) and by name. choosing the repo will clone it to the current directory i
476 lines (410 loc) • 15.8 kB
JavaScript
import _ from 'lodash'
import chalk from 'chalk';
import inquirer from 'inquirer';
import inquirerAutocomplete from 'inquirer-autocomplete-prompt';
import { getGitHubRepos, getMyUserName } from './axiosUtils.js';
import { isGitRepository } from './gitUtils.js';
import { handleBranchSelection } from './branchMenu.js';
import { isRepositoryCloned, organizeRepositories, cloneRepo, sortReposByType } from './utils.js';
import { exit, goBack, settingsOption, sortingFields, sortingDirections, settings, defaultSettings, restoreDefault, pageSizeLimits } from './constants.js';
import os from 'os';
import fs from 'fs/promises';
import path from 'path';
inquirer.registerPrompt('autocomplete', inquirerAutocomplete);
let organizedData = null;
let ownerName = null;
const currentPath = [];
let currentSelectioData = organizedData;
let cliSpinner = null;
let userSettings = { ...defaultSettings };
const CONFIG_PATH = path.join(os.homedir(), '.github-cloner-config.json');
const loadSettings = async () => {
try {
const configData = await fs.readFile(CONFIG_PATH, 'utf8');
userSettings = { ...defaultSettings, ...JSON.parse(configData) };
} catch (error) {
// If file doesn't exist, use defaults
userSettings = { ...defaultSettings };
}
};
const saveSettings = async () => {
try {
await fs.writeFile(CONFIG_PATH, JSON.stringify(userSettings, null, 2));
} catch (error) {
console.error('Failed to save settings:', error);
}
};
const checkGithubToken = async () => {
const token = process.env.GITHUB_TOKEN;
if (!token) {
const { newToken } = await inquirer.prompt({
type: 'input',
name: 'newToken',
message: 'No GitHub token found. Please enter your GitHub token:',
validate: input => input.length > 0 || 'Token is required'
});
// Add token to user's shell profile
const shellProfile = process.env.SHELL.includes('zsh') ? '.zshrc' : '.bashrc';
const profilePath = path.join(os.homedir(), shellProfile);
try {
await fs.appendFile(profilePath, `\nexport GITHUB_TOKEN="${newToken}"\n`);
console.log(chalk.green(`\nToken added to ${shellProfile}. Please restart your terminal or run:`));
console.log(chalk.cyan(`source ~/${shellProfile}\n`));
process.env.GITHUB_TOKEN = newToken;
} catch (error) {
console.error('Failed to save token:', error);
process.exit(1);
}
}
return process.env.GITHUB_TOKEN;
};
const maskToken = (token) => {
if (!token) return 'No token found';
return `${token.substring(0, 4)}...${token.substring(token.length - 4)}`;
};
const handleTokenSettings = async () => {
const currentToken = process.env.GITHUB_TOKEN;
const { tokenAction } = await inquirer.prompt({
type: 'list',
name: 'tokenAction',
message: 'GitHub Token Settings:',
choices: [
{
name: `Current token: ${chalk.cyan(maskToken(currentToken))}`,
value: 'show'
},
{
name: 'Set new token',
value: 'set'
},
{ name: chalk.yellow(goBack), value: goBack },
{ name: chalk.yellow(exit), value: exit }
]
});
if (tokenAction === exit) {
console.log(chalk.yellow('Bye!'));
process.exit(0);
}
if (tokenAction === goBack) {
return handleSettings();
}
if (tokenAction === 'set') {
const { newToken } = await inquirer.prompt({
type: 'password',
name: 'newToken',
message: 'Enter your GitHub token:',
validate: input => input.length > 0 || 'Token is required'
});
// Add token to user's shell profile
const shellProfile = process.env.SHELL.includes('zsh') ? '.zshrc' : '.bashrc';
const profilePath = path.join(os.homedir(), shellProfile);
try {
// Check if GITHUB_TOKEN already exists in the file
let profileContent = await fs.readFile(profilePath, 'utf8');
const tokenRegex = /export GITHUB_TOKEN=["'].*["']/;
if (tokenRegex.test(profileContent)) {
// Replace existing token
profileContent = profileContent.replace(
tokenRegex,
`export GITHUB_TOKEN="${newToken}"`
);
await fs.writeFile(profilePath, profileContent);
} else {
// Add new token
await fs.appendFile(
profilePath,
`\n# Added by GitHub Cloner CLI\nexport GITHUB_TOKEN="${newToken}"\n`
);
}
process.env.GITHUB_TOKEN = newToken;
console.log(chalk.green(`\nToken saved to ${shellProfile}. To use it right away, run:`));
console.log(chalk.cyan(`source ~/${shellProfile}\n`));
const { action } = await inquirer.prompt({
type: 'list',
name: 'action',
message: 'What would you like to do?',
choices: [
{ name: 'Return to token settings', value: 'return' },
{ name: chalk.yellow(exit), value: exit }
]
});
if (action === exit) {
console.log(chalk.yellow('Bye!'));
process.exit(0);
}
} catch (error) {
console.error(chalk.red('Failed to save token:'), error);
await new Promise(resolve => setTimeout(resolve, 2000)); // Give time to read error
}
}
// Return to token settings
return handleTokenSettings();
};
const handleSettings = async () => {
const { settingOption } = await inquirer.prompt({
type: 'list',
name: 'settingOption',
message: 'Settings:',
choices: [
{ name: 'Sort repositories by', value: 'sorting' },
{ name: 'Page size', value: 'pageSize' },
{
name: `GitHub Token ${chalk.dim(`(${maskToken(process.env.GITHUB_TOKEN)})`)}`,
value: 'token'
},
{ name: chalk.yellow(goBack), value: goBack },
{ name: chalk.yellow(exit), value: exit }
]
});
if (settingOption === 'token') {
return handleTokenSettings();
}
if (settingOption === exit) {
console.log(chalk.yellow('Bye!'));
process.exit(0);
}
if (settingOption === goBack) {
return displayMenu();
}
if (settingOption === 'token') {
await checkGithubToken();
return handleSettings();
}
if (settingOption === 'pageSize') {
const currentSize = userSettings[settings.display.pageSize];
const { newPageSize } = await inquirer.prompt({
type: 'list',
name: 'newPageSize',
message: `Select page size (current: ${currentSize}):`,
choices: [
...Array.from(
{ length: pageSizeLimits.max - pageSizeLimits.min + 1 },
(_, i) => ({
name: `${i + pageSizeLimits.min}${(i + pageSizeLimits.min) === pageSizeLimits.default ? ' (default)' : ''}`,
value: i + pageSizeLimits.min
})
),
{ name: chalk.blue(restoreDefault), value: 'default' },
{ name: chalk.yellow(goBack), value: goBack },
{ name: chalk.yellow(exit), value: exit }
],
default: currentSize
});
if (newPageSize === exit) {
console.log(chalk.yellow('Bye!'));
process.exit(0);
}
if (newPageSize === goBack) {
return handleSettings();
}
if (newPageSize === 'default') {
userSettings[settings.display.pageSize] = pageSizeLimits.default;
} else {
userSettings[settings.display.pageSize] = newPageSize;
}
await saveSettings();
console.log(chalk.green(`Page size updated to: ${userSettings[settings.display.pageSize]}`));
return handleSettings();
}
// Sorting settings
const { sortField } = await inquirer.prompt({
type: 'list',
name: 'sortField',
message: `Sort repositories by (current: ${userSettings[settings.sorting.field]}):`,
choices: [
{ name: 'Name', value: sortingFields.name },
{ name: 'Last update', value: sortingFields.pushedAt },
{ name: chalk.blue(restoreDefault), value: 'default' },
{ name: chalk.yellow(goBack), value: goBack },
{ name: chalk.yellow(exit), value: exit }
],
default: userSettings[settings.sorting.field]
});
if (sortField === exit) {
console.log(chalk.yellow('Bye!'));
process.exit(0);
}
if (sortField === goBack) {
return handleSettings();
}
const finalSortField = sortField === 'default' ?
defaultSettings[settings.sorting.field] :
sortField;
const { sortDirection } = await inquirer.prompt({
type: 'list',
name: 'sortDirection',
message: `Sort direction (current: ${userSettings[settings.sorting.direction]}):`,
choices: [
{ name: 'Ascending (A-Z)', value: sortingDirections.ascending },
{ name: 'Descending (Z-A)', value: sortingDirections.descending },
{ name: chalk.blue(restoreDefault), value: 'default' },
{ name: chalk.yellow(goBack), value: goBack },
{ name: chalk.yellow(exit), value: exit }
],
default: userSettings[settings.sorting.direction]
});
if (sortDirection === exit) {
console.log(chalk.yellow('Bye!'));
process.exit(0);
}
if (sortDirection === goBack) {
return handleSettings();
}
const finalSortDirection = sortDirection === 'default' ?
defaultSettings[settings.sorting.direction] :
sortDirection;
userSettings[settings.sorting.field] = finalSortField;
userSettings[settings.sorting.direction] = finalSortDirection;
await saveSettings();
// Re-sort the data if we're in a repository list
if (Array.isArray(currentSelectioData)) {
currentSelectioData = sortReposByType(
currentSelectioData,
userSettings[settings.sorting.field],
userSettings[settings.sorting.direction]
);
}
console.log(chalk.green('Sorting settings updated!'));
return handleSettings();
};
const tryCloneRepo = async (repoName) => {
const selectedRepo = currentSelectioData.find(repo => repo.name === repoName);
const cloneUrl = selectedRepo.cloneUrl;
const isCloned = await isRepositoryCloned(repoName);
if (isCloned) {
console.log(chalk.red(`Repository ${repoName} is already cloned.`));
currentPath.pop();
currentSelectioData = currentPath.length === 0 ? organizedData : _.get(organizedData, currentPath);
await displayMenu();
return;
}
cliSpinner.text = `Cloning ${repoName}...`;
cliSpinner.start();
await cloneRepo(cliSpinner, cloneUrl, repoName);
}
const setCurrentPathAndSelectionData = (choice) => {
if (choice) {
if (choice === exit) {
console.log(chalk.yellow('Bye!'));
process.exit(0);
}
if (choice === goBack && currentPath.length > 0) {
currentPath.pop();
}
if (currentSelectioData[choice]) {
currentPath.push(choice);
}
}
currentSelectioData = currentPath.length === 0 ? organizedData : _.get(organizedData, currentPath);
}
const createChoicesList = (items, isFirstMenu) => {
let choices;
if (isFirstMenu) {
choices = [
...Object.keys(items).map(key => ({
name: key,
value: key,
searchValue: key
})),
{
name: chalk.blue(settingsOption),
value: settingsOption,
searchValue: settingsOption
}
];
} else if (Array.isArray(items)) {
choices = sortReposByType(
items,
userSettings[settings.sorting.field],
userSettings[settings.sorting.direction]
).map(repo => ({
name: `${repo.name} ${chalk.dim(`(${repo.owner})`)}`,
value: repo.name,
searchValue: `${repo.name} ${repo.owner}`
}));
} else {
// Handle other object menus
choices = Object.keys(items).map(key => ({
name: key,
value: key,
searchValue: key
}));
}
// Add navigation options
choices.push({
name: chalk.yellow(isFirstMenu ? exit : goBack),
value: isFirstMenu ? exit : goBack,
searchValue: isFirstMenu ? exit : goBack
});
return choices;
};
const searchChoices = (choices, input = '') => {
if (!input) return choices;
const searchTerm = input.toLowerCase();
return choices.filter(choice => {
const searchValue = choice.searchValue.toLowerCase();
return searchValue.includes(searchTerm);
});
};
const displayMenu = async (userChoice) => {
if (userChoice === settingsOption) {
return handleSettings();
}
setCurrentPathAndSelectionData(userChoice);
const isList = Array.isArray(currentSelectioData);
const isFirstMenu = currentPath.length === 0;
const choices = createChoicesList(currentSelectioData, isFirstMenu);
const answer = await inquirer.prompt({
type: 'autocomplete',
name: isList ? 'repoName' : 'choice',
message: isList ? 'Choose a repository to clone (type to search):' : 'Choose an option (type to search):',
choices,
source: (answersYet, input) => {
return Promise.resolve(searchChoices(choices, input));
},
pageSize: userSettings[settings.display.pageSize]
});
const specificAnswer = isList ? answer.repoName : answer.choice;
if (isList && specificAnswer !== goBack && specificAnswer !== exit) {
await tryCloneRepo(specificAnswer);
} else {
await displayMenu(specificAnswer);
}
};
export const runCli = async (spinner) => {
cliSpinner = spinner;
cliSpinner.start();
try {
await loadSettings();
await checkGithubToken();
// Check if current directory is a git repository
const isGitRepo = await isGitRepository();
if (isGitRepo) {
cliSpinner.stop(); // Stop the spinner before showing the menu
const { shouldClone } = await handleBranchSelection(process.env.GITHUB_TOKEN);
if (!shouldClone) {
process.exit(0);
}
cliSpinner.start(); // Restart spinner if continuing to clone flow
}
if (!ownerName) {
cliSpinner.text = 'Fetching GitHub user name...';
ownerName = await getMyUserName();
if (!ownerName) {
cliSpinner.fail();
console.error(chalk.red(`Failed to fetch GitHub user name. Make sure your GitHub token is valid.`));
process.exit(1);
}
}
if (!organizedData) {
cliSpinner.text = 'Fetching your GitHub data...';
const userRepos = await getGitHubRepos();
organizedData = organizeRepositories(userRepos, ownerName);
}
cliSpinner.succeed();
await displayMenu();
} catch (error) {
cliSpinner.fail();
console.error('An error occurred:', error);
}
}