@ehubbell/gitty
Version:
A simple CLI that will fetch, store, and clone Github repos.
473 lines (472 loc) • 18.9 kB
JavaScript
;
const os = require("node:os");
const sade = require("sade");
const ora = require("ora");
const Fs = require("fs-extra");
const simpleGit = require("simple-git");
const core = require("@octokit/core");
const Archiver = require("archiver");
const Stream = require("fstream");
const path = require("node:path");
const Unzip = require("unzip-stream");
const chalk = require("chalk");
const version = "0.5.0";
const checkOrCreateFile = async (pathName, fileName) => {
const fileExists = await checkPath(`${pathName}/${fileName}`);
return fileExists ? fileExists : await createFile(pathName, fileName);
};
const createFile = async (pathName, fileName) => {
await createPath(pathName);
return await Fs.promises.writeFile(`${pathName}/${fileName}`, "");
};
const readFile = async (filePath, encoding = "utf8") => {
return await Fs.promises.readFile(filePath, encoding);
};
const checkPath = async (pathName) => {
return await Fs.promises.stat(pathName).then(() => true).catch(() => false);
};
const createPath = async (pathName) => {
return await Fs.mkdirSync(pathName, { recursive: true });
};
const checkOrCreatePath = async (pathName) => {
const pathExists = await checkPath(pathName);
return pathExists ? pathExists : await createPath(pathName);
};
const fileStats = async (filePath) => {
return await Fs.promises.stat(filePath);
};
const removePath = async (pathName) => {
const pathExists = await checkPath(pathName);
if (pathExists) await Fs.rm(pathName, { recursive: true });
};
const writeFile = async (filePath, content) => {
return await Fs.writeFile(filePath, content);
};
class ConfigService {
constructor(props) {
this.basePath = props.basePath;
}
/* ----- Methods ----- */
async setup() {
const fragments = this.basePath.split("/");
const path2 = fragments.filter((v, i) => i < fragments.length - 1).join("/");
const file = fragments[fragments.length - 1];
return await checkOrCreateFile(path2, file);
}
async checkEmpty() {
const pathExists = await checkPath(this.basePath);
if (pathExists) {
const path2 = await fileStats(this.basePath);
if (path2.isDirectory()) return false;
return true;
}
return false;
}
async readContents() {
const contents = await readFile(this.basePath);
const records = contents.split("\n");
const formattedRecords = {};
records.filter((v) => v.length > 0).map((record) => {
const key = record.split("=")[0];
const value = record.split("=")[1];
return formattedRecords[key] = value;
});
return formattedRecords;
}
}
class GitService {
constructor(props) {
this.basePath = props.basePath;
this.token = props.token;
this.addConfig("user.name", "playbooks");
this.addConfig("user.email", "admin@playbooks.xyz");
}
/* ----- Computed ----- */
get client() {
return simpleGit.simpleGit({
baseDir: this.basePath,
binary: "git",
maxConcurrentProcesses: 6
});
}
/* ----- Helpers ----- */
addConfig(key, value) {
return this.client.addConfig(key, value, false, simpleGit.GitConfigScope.local);
}
/* ----- Methods ----- */
async create(ownerId, repoId) {
return await this.client.init().add(".").commit("Transfer clone").addRemote("origin", `https://${this.token}@github.com/${ownerId}/${repoId}.git`).branch(["-M", "main"]).push(["-u", "origin", "main"]);
}
async clone(remoteUrl, localPath) {
return await this.client.clone(remoteUrl, localPath);
}
async fetch(options = {}) {
await this.client.fetch(options);
}
async pull(options = {}) {
await this.client.pull(options);
}
async push(options = {}) {
await this.client.push(options);
}
async remove(branch = "origin/main") {
await this.client.removeRemote(branch);
}
async resetRepo(branch = "origin/main") {
await this.client.reset(simpleGit.ResetMode.HARD, { branch: null });
}
}
class GithubService {
constructor(props) {
this.token = props.token;
}
/* ----- Computes ----- */
get client() {
return new core.Octokit({ auth: this.token, userAgent: "gitto/v0.0.0" });
}
// Queries
async getRepo(ownerId, repoId) {
try {
const endpoint = `/repos/${ownerId}/${repoId}`;
const response = await this.client.request(`GET ${endpoint}`);
return { status: response.status, data: response.data };
} catch (e) {
return { status: e.status, message: e.message };
}
}
async getRepoZip(ownerId, repoId) {
try {
const endpoint = `/repos/${ownerId}/${repoId}/zipball`;
const response = await this.client.request(`GET ${endpoint}`);
return { status: response.status, data: response.data };
} catch (e) {
return { status: e.status, message: e.message };
}
}
async getRepoVersionZip(ownerId, repoId, ref) {
try {
const endpoint = `/repos/${ownerId}/${repoId}/zipball/${ref}`;
const response = await this.client.request(`GET ${endpoint}`);
return { status: response.status, data: response.data };
} catch (e) {
return { status: e.status, message: e.message };
}
}
async getOrgs(params = {}) {
try {
const endpoint = `/user/orgs`;
const response = await this.client.request(`GET ${endpoint}`, params);
return { status: response.status, data: response.data };
} catch (e) {
return { status: e.status, message: e.message };
}
}
async createRepo(ownerId, data) {
try {
const response = await this.getOrgs();
if (response.status !== 200) throw response;
const hasOrg = response.data.find((v) => v.login === ownerId);
return hasOrg ? await this.createOrgRepo(ownerId, data) : await this.createPersonalRepo(data);
} catch (e) {
return { status: e.status, message: e.message };
}
}
async createPersonalRepo(data) {
try {
const endpoint = `/user/repos`;
const response = await this.client.request(`POST ${endpoint}`, data);
return { status: response.status, data: response.data };
} catch (e) {
return { status: e.status, message: e.message };
}
}
async createOrgRepo(ownerId, data) {
try {
const endpoint = `/orgs/${ownerId}/repos`;
const response = await this.client.request(`POST ${endpoint}`, data);
return { status: response.status, data: response.data };
} catch (e) {
return { status: e.status, message: e.message };
}
}
}
class StorageService {
constructor(props) {
this.basePath = props.basePath;
this.fileName = props?.fileName;
this.nestedPath = props?.nestedPath;
}
/* ----- Computed ----- */
get zipFile() {
return `${this.basePath}/${this.fileName}.zip`;
}
/* ----- Methods ----- */
async checkEmpty() {
const formattedPath = path.join(this.basePath, this.fileName);
const pathExists = await checkPath(formattedPath);
if (pathExists) {
const path2 = await fileStats(formattedPath);
if (path2.isDirectory()) {
const entries = await Fs.promises.readdir(formattedPath);
const filteredEntries = entries.filter((v) => v.slice(0, 1) !== ".");
return filteredEntries.length > 0 ? false : true;
}
return true;
}
return true;
}
async saveRepo(buffer) {
await checkOrCreatePath(this.basePath);
await writeFile(this.zipFile, Buffer.from(buffer));
}
async fetchRepoStats() {
return await fileStats(this.basePath);
}
async unzipRepo() {
await checkOrCreatePath(this.basePath);
return await new Promise((resolve, reject) => {
Fs.createReadStream(this.zipFile).pipe(Unzip.Parse()).on("entry", (entry) => {
const type = entry.type;
const fileName = entry.path;
const slicedPath = fileName.slice(fileName.indexOf("/"), fileName.length);
const formattedPath = path.join(this.fileName, slicedPath);
const updatedPath = formattedPath.replace("/" + this.nestedPath, "/");
const finalPath = path.join(this.basePath, updatedPath);
if (type === "Directory") return entry.autodrain();
if (this.nestedPath) {
const rootPath = slicedPath.split("/")[1];
const rootFile = rootPath.toLowerCase();
if (fileName.includes(this.nestedPath) || rootFile === "license.md") {
const writer = Stream.Writer({ path: finalPath });
return entry.pipe(writer);
}
return entry.autodrain();
} else {
const writer = Stream.Writer({ path: finalPath });
return entry.pipe(writer);
}
}).on("close", (v) => resolve(v)).on("error", (e) => reject(e));
});
}
async cleanRepo() {
await removePath(this.basePath + "/.git");
await removePath(this.basePath + "/.github");
}
async zipRepo() {
await checkOrCreatePath(this.basePath);
const archive = Archiver("zip", { zlib: { level: 9 } });
const stream = Fs.createWriteStream(this.zipFile);
await new Promise((resolve, reject) => {
stream.on("close", (v) => resolve(v));
archive.directory(this.basePath, false);
archive.on("error", (err) => reject(err));
archive.pipe(stream);
archive.finalize();
});
}
async removeRepo() {
await removePath(this.basePath);
}
async removeZip() {
await removePath(this.zipFile);
}
}
const formatError = (e) => {
switch (e.status || e.code) {
case 401:
return apiError(e);
case 403:
return apiError(e);
case 422:
return apiError(e);
case 500:
return apiError(e);
default:
return cliError(e);
}
};
const apiError = (e) => {
const data = JSON.parse(e.response.text);
const error = data.errors[0];
const { status, title, detail, framework } = error;
return { status, title, detail, framework };
};
const cliError = (e) => {
return { status: e.status || e.code || 500, title: e.name, detail: e.message, framework: e.stack };
};
const sleep = (ms) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
const formatName = (url) => {
const paths = url.split("?")[0].split("#")[0].split("/");
const name = paths[paths.length - 1];
return name.split(".")[0];
};
class logger {
static log = (title, ...data) => {
return;
};
static error = (title, ...data) => {
return console.log(chalk.red(title, ...data));
};
static warn = (title, ...data) => {
return console.log(chalk.yellow(title, ...data));
};
static info = (title, ...data) => {
return console.log(chalk.blue(title, ...data));
};
static success = (title, ...data) => {
return console.log(chalk.green(title, ...data));
};
static debug = (title, ...data) => {
return console.log(chalk.red(title, ...data));
};
}
const cloneCommand = async (url, options) => {
try {
const env = options.config;
const account = options.account || null;
const name = options.name || null;
const version2 = options.version || null;
logger.log("options: ", { env, account, name, version: version2 });
const configService = new ConfigService({ basePath: env });
const configSpinner = ora("Setting up...\n").start();
await sleep(300);
const configValid = await configService.checkEmpty();
if (!configValid) return configSpinner.fail("Please provide a valid config file.");
const config = await configService.readContents();
logger.log("config: ", config);
const githubPath = url.includes("github.com") ? url.split("https://github.com/")[1] : url;
const githubFragments = githubPath.split("/");
const ownerId = githubFragments[0];
const repoId = githubFragments[1];
const nestedPath = githubFragments.includes("tree") ? githubFragments.slice(4, githubPath.length).join("/") : githubFragments.slice(2, githubPath.length).join("/");
logger.log("url: ", { ownerId, repoId, nestedPath });
const basePath = process.cwd();
const fileName = name || githubFragments[githubFragments.length - 1];
logger.log("destination: ", { basePath, fileName });
configSpinner.succeed("Setup complete!");
const githubService = new GithubService({ token: config.GITHUB_TOKEN });
const githubSpinner = ora("Fetching repo...\n").start();
await sleep(300);
const zipResponse = version2 ? await githubService.getRepoVersionZip(ownerId, repoId, version2) : await githubService.getRepoZip(ownerId, repoId);
if (zipResponse.status !== 200) {
githubSpinner.fail("Fetch failed!");
return logger.error("Github: ", JSON.stringify(zipResponse));
}
githubSpinner.succeed("Fetch complete!");
const storageService = new StorageService({ basePath, fileName, nestedPath });
const storageSpinner = ora("Storing repo...\n").start();
await sleep(300);
const storageEmpty = await storageService.checkEmpty();
if (!storageEmpty) return storageSpinner.fail(`Please clear directory: ${basePath}/${fileName}`);
await storageService.saveRepo(zipResponse.data);
await storageService.unzipRepo();
await storageService.cleanRepo();
await storageService.removeZip();
storageSpinner.succeed("Storage complete!");
const cloneSpinner = ora("Checking github...\n").start();
await sleep(300);
const repoResponse = await githubService.getRepo(account, fileName);
if (repoResponse.status !== 404) {
cloneSpinner.fail("Repo already exists!");
return logger.error("github: ", JSON.stringify(repoResponse));
}
cloneSpinner.text = "Creating repo...";
const createResponse = await githubService.createRepo(account, {
name: fileName,
private: false
});
if (createResponse.status !== 201) {
cloneSpinner.fail("Create failed!");
return logger.error("github: ", JSON.stringify(createResponse));
}
cloneSpinner.text = "Cloning repo...";
const gitService = new GitService({ basePath, token: config.GITHUB_TOKEN });
await gitService.create(account, fileName);
cloneSpinner.succeed("Clone complete!");
} catch (e) {
logger.error(formatError(e));
process.exit();
}
};
const configCommand = async (options) => {
const spinner = ora("Fetching config...");
try {
const config = options.c || options.config;
logger.log("options: ", { config });
spinner.start();
await sleep(300);
const service = new ConfigService({ basePath: config });
await service.setup();
const contents = await service.readContents();
const data = {};
Object.keys(contents).map((key) => {
if (key.toLowerCase().includes("token")) return data[key] = "********";
data[key] = contents[key];
});
const formattedResponse = Object.keys(data).length > 0 ? JSON.stringify(data, null, 2) : "Nothing to see yet.";
spinner.succeed();
logger.success(`Config [${config}]: `, formattedResponse);
} catch (e) {
spinner.fail();
logger.error(e);
process.exit();
}
};
const downloadCommand = async (url, options) => {
try {
const env = options.config;
const path2 = options.path || process.cwd();
const name = options.name || formatName(url);
const branch = options.branch || null;
const version2 = options.version || null;
const ref = branch || version2;
logger.log("options: ", { env, path: path2, name, ref });
const configService = new ConfigService({ basePath: env });
const configSpinner = ora("Setting up...\n").start();
await sleep(300);
const configValid = await configService.checkEmpty();
if (!configValid) return configSpinner.fail("Please provide a valid config file.");
const config = await configService.readContents();
logger.log("config: ", config);
const githubPath = url.includes("github.com") ? url.split("https://github.com/")[1] : url;
const githubFragments = githubPath.split("/");
const ownerId = githubFragments[0];
const repoId = githubFragments[1];
const nestedPath = githubFragments.includes("tree") ? githubFragments.slice(4, githubPath.length).join("/") : githubFragments.slice(2, githubPath.length).join("/");
logger.log("url: ", { ownerId, repoId, nestedPath });
configSpinner.succeed("Setup complete!");
const githubService = new GithubService({ token: config.GITHUB_TOKEN });
const githubSpinner = ora("Fetching repo...\n").start();
await sleep(300);
const zipResponse = ref ? await githubService.getRepoVersionZip(ownerId, repoId, ref) : await githubService.getRepoZip(ownerId, repoId);
if (zipResponse.status !== 200) {
githubSpinner.fail("Fetch failed!");
return logger.error("Github: ", JSON.stringify(zipResponse));
}
githubSpinner.succeed("Fetch complete!");
const storageService = new StorageService({ basePath: path2, fileName: name, nestedPath });
const storageSpinner = ora("Storing repo...\n").start();
await sleep(300);
const storageEmpty = await storageService.checkEmpty();
if (!storageEmpty) return storageSpinner.fail(`Please clear directory: ${path2}/${name}`);
await storageService.saveRepo(zipResponse.data);
await storageService.unzipRepo();
await storageService.cleanRepo();
await storageService.removeZip();
storageSpinner.succeed("Storage complete!");
} catch (e) {
logger.error(formatError(e));
process.exit();
}
};
const cli = sade("gitty");
cli.version(version).describe("A simple CLI to fetch, store and clone Github repositories.").option("--config", "Path to your config file.", `${os.homedir()}/.gittyrc`);
cli.command("config").describe("Display your config file.").example("gitty config").action(configCommand);
cli.command("clone <url>").describe("Clone a Github repo or subdirectory to your account.").option("--path", "Specify path to a local directory (defaults to CWD).").option("--account", "Specify the account where we should add this clone.").option("--name", "Specify the name for cloned repository.").option("--version", "Specify tarball version (optional).").example("gitty clone https://github.com/vercel/next.js").example("gitty clone https://github.com/vercel/next.js --account ehubbell").example("gitty clone https://github.com/vercel/next.js --account ehubbell --name vercel-copy").action(cloneCommand);
cli.command("download <url>").describe("Download a Github repo or subdirectory to your local file system.").option("--branch", "Specify the branch", "").option("--path", "Path to destination folder", ".").option("--name", "Name the downloaded repository").option("--version", "Specify tarball version (optional).").example("gitty download https://github.com/vercel/next.js").example(
"gitty download https://github.com/vercel/next.js/tree/main/examples/angular --path ~/templates --name angular"
).example("gitty download https://github.com/vercel/next.js/tree/main/examples/angular --unzip --clean --remove").action(downloadCommand);
cli.parse(process.argv);