UNPKG

@ehubbell/gitty

Version:

A simple CLI that will fetch, store, and clone Github repos.

473 lines (472 loc) 18.9 kB
#!/usr/bin/env node "use strict"; 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);