UNPKG

vag_tools

Version:

api and cli for managing sub-git-repositories

851 lines (846 loc) 27.2 kB
#!/usr/bin/env node // src/vag_cli.ts import yargs from "yargs"; import { hideBin } from "yargs/helpers"; // src/vag_core.ts import path from "path"; import fs from "fs"; import fsp from "fs/promises"; import fse from "fs-extra"; import YAML from "yaml"; import { simpleGit } from "simple-git"; // package.json var package_default = { name: "vag_tools", version: "0.1.5", description: "api and cli for managing sub-git-repositories", private: false, repository: { type: "git", url: "git+https://github.com/charlyoleg2/vag.git" }, keywords: [ "git", "sub-repo", "multi-repo", "submodule", "subtree", "vcs", "vcstool", "repositories" ], author: "charlyoleg", license: "ISC", bugs: { url: "https://github.com/charlyoleg2/vag/issues" }, homepage: "https://charlyoleg2.github.io/vag/", type: "module", engines: { node: ">=20.10.0" }, exports: { ".": { types: "./dist/vag_core.d.ts", default: "./dist/vag_core.js" } }, bin: { vag: "dist/vag_cli.js", vagg: "dist/vagg_server.js" }, files: [ "dist/", "!dist/**/*.map", "!dist/**/*.test.*", "!dist/**/*.spec.*" ], prettier: { useTabs: true, singleQuote: true, trailingComma: "none", printWidth: 100, plugins: [], overrides: [] }, tsup: { entry: [ "src/vag_core.ts", "src/vag_cli.ts", "src/vagg_server.ts", "src/tester_websocket_client.ts" ], format: "esm", splitting: false, dts: true, sourcemap: true, clean: true }, scripts: { dev: "tsup --watch", build: "tsup", check: "tsc --noEmit", pretty: "prettier --check .", format: "prettier --write .", lint: "eslint .", "test:unit": "vitest", "test:unit:once": "vitest --run", clean_public: "shx rm -fr dist/public", copy: "shx cp -r ../vag_ui/build dist/public", checkCopy: "ls -l dist/public/index.html", ci: "run-s check build pretty lint test:unit:once clean_public copy checkCopy", clean: "shx rm -fr build dist tmp node_modules", "run:cli": "node dist/vag_cli.js", "run:server": "node dist/vagg_server.js", "run:clients": "tests/run_clients.sh", clean_all: "run-s clean" }, dependencies: { "@hono/node-server": "^1.19.7", "fs-extra": "^11.3.3", hono: "^4.11.3", "simple-git": "^3.30.0", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", yaml: "^2.8.2", yargs: "^18.0.0" }, devDependencies: { "@types/fs-extra": "^11.0.4", "@types/node": "^25.0.3", "@types/yargs": "^17.0.35", eslint: "^9.39.2", "npm-run-all2": "^8.0.4", prettier: "^3.7.4", shx: "^0.4.0", tsup: "^8.5.1", typescript: "^5.9.3", "typescript-eslint": "^8.50.1", vitest: "^4.0.16" } }; // src/vag_core.ts var vag_version_short = package_default.version; var regex_pointSlash = /^\.\//; var regex_trailingSlash = /\/$/; async function isGitRepo(pathDir2) { let isRepo = false; let isRepoCandidate = false; const subdirs = await fse.readdir(pathDir2, { withFileTypes: true }); for (const subitem of subdirs) { if (subitem.isDirectory() && subitem.name === ".git") { isRepoCandidate = true; } } if (isRepoCandidate) { try { const git2 = simpleGit(pathDir2); const git2cmd = await git2.revparse(["--show-prefix"]); if (git2cmd === "") { isRepo = true; } } catch (err) { console.log(err); } if (!isRepo) { console.log( `INFO299: the directory ${pathDir2} contains a sub-directory .git but is not a git-repository` ); } } return isRepo; } async function searchGitRepo(pathDir, deepSearch = true) { let r_list = []; const local_list = await fse.readdir(pathDir, { withFileTypes: true }); for (const item of local_list) { if (item.isDirectory()) { const pathDir2 = pathDir + "/" + item.name; if ([".git", "node_modules"].includes(item.name)) { } else { const isRepo = await isGitRepo(pathDir2); if (isRepo) { r_list.push(pathDir2); } if (deepSearch || !isRepo) { r_list = r_list.concat(await searchGitRepo(pathDir2, deepSearch)); } } } } return r_list; } function array_intersection(arr1, arr2) { return arr1.filter((elem) => arr2.includes(elem)); } function array_exclude(arr_base, arr_exclude) { return arr_base.filter((elem) => !arr_exclude.includes(elem)); } async function git_clone(localPath, remote_url, version) { let r_code = -1; try { if (!fs.existsSync(localPath)) { const git = simpleGit(); const gitlog = await git.clone(remote_url, localPath); console.log(gitlog); r_code = await git_checkout(localPath, version); } else { const fstat = await fsp.stat(localPath); if (fstat.isDirectory()) { const isRepo = await isGitRepo(localPath); if (isRepo) { const git2 = simpleGit(localPath); const remote = await git2.getRemotes(true); const remote_url2 = remote[0].refs.fetch; if (remote_url2 === remote_url2) { console.log( `INFO398: the git-repo ${localPath} is already cloned! Then just git-pull!` ); const gitlog2 = await git2.pull(); console.log(gitlog2); r_code = await git_checkout(localPath, version); } else { console.log( `WARN381: Warning, the git-repo ${localPath} already exist but with an unexpected remote! git-clone/pull aborted!` ); } } else { console.log( `WARN869: Warning, the directory ${localPath} already exist but is not a git-repo! git-clone aborted!` ); } } else { console.log( `WARN537: Warning, the path ${localPath} already exist and is a file! git-clone aborted!` ); } } } catch (error) { console.log(`ERR162: Error by cloning ${localPath} from ${remote_url}`); console.error(error); } return r_code; } async function git_checkout(repoPath, version) { let r_code = -2; try { const git = simpleGit(repoPath); const gitlog = await git.checkout(version); console.log(gitlog); r_code = 0; } catch (error) { console.log(`ERR523: Error by checkout ${repoPath} for version ${version}`); console.error(error); } return r_code; } async function git_verify(repoPath, remote_url, version) { let r_code = 0; const one_info = await one_repo_info(repoPath); if (one_info.url === remote_url) { console.log(`remote_url: Ok`); } else { console.log(`remote_url: Nok ${remote_url} versus ${one_info.url}`); r_code = -1; } if (one_info.branch === version || one_info.commit === version) { console.log(`version: Ok`); } else { console.log( `version: Nok ${version} versus ${one_info.branch} versus ${one_info.commit}` ); r_code = -1; } return r_code; } async function git_custom(repoPath, gitCommand) { let r_code = -1; try { const git = simpleGit(repoPath); const gitCommand2 = gitCommand.split(" "); const gitlog = await git.raw(...gitCommand2); console.log(gitlog); r_code = 0; } catch (error) { console.log(`ERR772: Error by git-command ${gitCommand} on repo ${repoPath}`); console.error(error); } return r_code; } async function one_repo_info(localPath) { let one_info = { localPath: "undefined", url: "undefined", branch: "undefined", commit: "undefined" }; const localPath2 = localPath.replace(regex_pointSlash, ""); try { const git = simpleGit(localPath); const remote = await git.getRemotes(true); const remote_url = remote[0].refs.fetch; const branch = await git.branch(); const branch_current = branch.current; const commit = await git.log(); const commit_hash = commit.latest.hash; one_info = { localPath: localPath2, url: remote_url, branch: branch_current, commit: commit_hash }; } catch (error) { console.log(`ERR398: Error by git-operations on repo ${localPath}`); console.error(error); } return one_info; } async function get_repos_info(repos) { const repos_info = []; for (const [idx, localPath] of repos.entries()) { console.log(`===> ${idx + 1} - get info of git-repo ${localPath}`); const one_info = await one_repo_info(localPath); repos_info.push(one_info); } return repos_info; } async function validate_yaml_external(yamlPath) { let fyaml; try { const fstr = await fse.readFile(yamlPath, "utf-8"); fyaml = YAML.parse(fstr); } catch (error) { console.log(`ERR439: Error by reading the yaml-file ${yamlPath}!`); console.error(error); return -1; } try { if (!Object.hasOwn(fyaml, "repositories")) throw 'The property "repositories" is missing!'; for (const repo in fyaml.repositories) { if (!Object.hasOwn(fyaml.repositories[repo], "url")) throw `The property "url" is missing for repo ${repo} !`; if (!Object.hasOwn(fyaml.repositories[repo], "version")) throw `The property "version" is missing for repo ${repo} !`; if (!Object.hasOwn(fyaml.repositories[repo], "type")) { console.log(`WARN390: Warning, the property "type" is missing for repo ${repo} !`); } else if (fyaml.repositories[repo].type !== "git") { console.log( `WARN395: Warning, the property "type" of repo ${repo} is not git but ${fyaml.repositories[repo].type}!` ); } } console.log(`The yaml-file ${yamlPath} is valid!`); return 0; } catch (error) { console.error(error); console.log(`Invalid yaml-file ${yamlPath}!`); return -2; } } function isPathAbsolute(path2) { let r_absolute = false; const regex_slash = /^\//; if (regex_slash.test(path2)) { r_absolute = true; } return r_absolute; } var Vag = class { /** The top directory from where to search repositories. */ discoverDir; /** When a repository is encountered, should it be inspected for searching sub-repositories? */ deepSearch; /** The path to the yaml-file that provides the repositories to be cloned. */ importYaml; /** The base directory where the directories listed in the yaml-file should be cloned. */ importDir; /** The list of the path of the discovered repositories. It is populated by the `init()` method. */ listD; /** An object with the content of the yaml-file. It is populated by the `init()` method. */ listC; /** * The constructor of the `Vag` class. * `listD` is initialized to an empty list. * `listC` is initialized to an empty object. */ constructor(discoverDir = ".", deepSearch = true, importYaml = "", importDir = "") { this.discoverDir = discoverDir; this.deepSearch = deepSearch; this.importYaml = importYaml; this.importDir = importDir; this.listD = []; this.listC = {}; } // this init function cannot be included in the constructor because the constructor can not be async async discover_repos(discoverDir = this.discoverDir, deepSearch = this.deepSearch) { this.discoverDir = discoverDir; this.deepSearch = deepSearch; if (this.discoverDir === "") { console.log(`ERR282: Error, the discoverDir cannot be an empty string`); return -1; } if (!isPathAbsolute(this.discoverDir)) { if (!regex_pointSlash.test(this.discoverDir) && this.discoverDir !== ".") { this.discoverDir = "./" + this.discoverDir; } } this.discoverDir = this.discoverDir.replace(regex_trailingSlash, ""); try { await fse.readdir(this.discoverDir, { withFileTypes: true }); } catch (err) { console.log(`ERR638: Error, the path ${this.discoverDir} doesn't exist!`); console.log(err); return -1; } this.listD = await searchGitRepo(this.discoverDir, this.deepSearch); console.log(`Number of discovered cloned git repos: ${this.listD.length}`); return 0; } async import_yaml(importYaml = this.importYaml, importDir = this.importDir) { let r_code = 0; this.importYaml = importYaml; this.importDir = importDir; if (this.importYaml !== "") { let baseDir = path.dirname(this.importYaml); if (this.importDir !== "") { baseDir = this.importDir; } if (["", "."].includes(baseDir)) { baseDir = ""; } else { if (!regex_trailingSlash.test(baseDir)) { baseDir = baseDir + "/"; } } const list_non_git = []; try { const fstr = await fse.readFile(this.importYaml, "utf-8"); const fyaml = YAML.parse(fstr); for (const repoDir in fyaml.repositories) { let repoDir2 = repoDir; if (!isPathAbsolute(repoDir2)) { repoDir2 = baseDir + repoDir; if (!regex_pointSlash.test(repoDir2)) { repoDir2 = "./" + repoDir2; } } if (!Object.hasOwn(fyaml.repositories[repoDir], "type") || fyaml.repositories[repoDir].type === "git") { this.listC[repoDir2] = { url: fyaml.repositories[repoDir].url, version: fyaml.repositories[repoDir].version }; } else { list_non_git.push(repoDir2); } } } catch (error) { console.log( `ERR826: Error, the imported-yaml-file ${this.importYaml} is not valid!` ); console.error(error); r_code = -1; } console.log( `From imported Yaml, number of git-repos: ${Object.keys(this.listC).length}` ); console.log(`From imported Yaml, number of excluded repos: ${list_non_git.length}`); for (const [idx, repoDir] of list_non_git.entries()) { console.log( ` ${(idx + 1).toString().padStart(3, " ")} - Excluded repo: ${repoDir}` ); } } return r_code; } /** * The `init` method populate populate `listD` and `listC`. * It must be called just after instanciating `Vag`. * The configuration properties `discoverDir`, `deepSearch`, `importYaml` and `importDir` can be reassinged by `init`. */ async init(discoverDir = this.discoverDir, deepSearch = this.deepSearch, importYaml = this.importYaml, importDir = this.importDir) { let r_code = 0; r_code += await this.discover_repos(discoverDir, deepSearch); r_code += await this.import_yaml(importYaml, importDir); return r_code; } /** List the discovered repositories. */ d_list() { return this.listD; } /** List the wished repositories (a.k.a. configured repositories). */ c_list() { return Object.keys(this.listC); } /** List the git-repos which are in the D-list and in the C-list. I.e. Intersection of D and C. */ cd_list() { return array_intersection(this.listD, Object.keys(this.listC)); } /** List the git-repos which are in the D-list but not in the C-list. I.e. D not C. */ dnc_list() { return array_exclude(this.listD, Object.keys(this.listC)); } /** List the git-repos which are in the C-list but not in the D-list. I.e. C not D. */ cnd_list() { return array_exclude(Object.keys(this.listC), this.listD); } /** Clone the repositories of `listC`. */ async c_clone() { let r_code = 0; for (const [idx, localPath] of Object.keys(this.listC).entries()) { const repo = this.listC[localPath]; console.log( `===> ${idx + 1} - clone ${localPath} from ${repo.url} at version ${repo.version}` ); r_code += await git_clone(localPath, repo.url, repo.version); } return r_code; } /** Checkout the repositories listed in `listC` and `listD`. */ async cd_checkout() { let r_code = 0; const list_cd = this.cd_list(); for (const [idx, localPath] of list_cd.entries()) { const repo = this.listC[localPath]; console.log(`===> ${idx + 1} - checkout ${localPath} at version ${repo.version}`); r_code += await git_checkout(localPath, repo.version); } return r_code; } /** For the repositories listed in `listC` and `listD`, verify if they fit with the configuration of Yaml-file. */ async cd_verify() { let r_code = 0; const list_cd = this.cd_list(); for (const [idx, localPath] of list_cd.entries()) { const repo = this.listC[localPath]; console.log(`===> ${idx + 1} - verify ${localPath}`); r_code += await git_verify(localPath, repo.url, repo.version); } const all_nb = list_cd.length; const nok_nb = Math.abs(r_code); const ok_nb = all_nb - nok_nb; console.log(`Verify ${all_nb} repos : ${ok_nb} Ok, ${nok_nb} Nok`); return r_code; } /** Run a custom git-command on the discoverd repositories (`listD`). */ async d_custom(git_command, only_configured_repo = false) { let r_code = 0; let repos = this.d_list(); if (only_configured_repo) { repos = this.cd_list(); } for (const [idx, localPath] of repos.entries()) { console.log( `===> ${idx + 1} - On git-repo ${localPath} with command git ${git_command}` ); r_code += await git_custom(localPath, git_command); } return r_code; } /** Git-fetch on the discoverd repositories (`listD`). */ async d_fetch(only_configured_repo = false) { return await this.d_custom("fetch --prune", only_configured_repo); } /** Git-pull on the discoverd repositories (`listD`). */ async d_pull(only_configured_repo = false) { return await this.d_custom("pull", only_configured_repo); } /** Git-push on the discoverd repositories (`listD`). */ async d_push(only_configured_repo = false) { return await this.d_custom("push", only_configured_repo); } /** Show the current branch of the discoverd repositories (`listD`). */ async d_branch(only_configured_repo = false) { return await this.d_custom("branch --show-current", only_configured_repo); } /** Git-status on the discoverd repositories (`listD`). */ async d_status(only_configured_repo = false) { return await this.d_custom("status", only_configured_repo); } /** Git-diff on the discoverd repositories (`listD`). */ async d_diff(only_configured_repo = false) { return await this.d_custom("diff", only_configured_repo); } /** `Git-log -n 3` on the discoverd repositories (`listD`). */ async d_log(only_configured_repo = false) { return await this.d_custom("log -n 3", only_configured_repo); } /** `Git-remote -vv` on the discoverd repositories (`listD`). */ async d_remote(only_configured_repo = false) { return await this.d_custom("remote -vv", only_configured_repo); } /** `Git-stash list` on the discoverd repositories (`listD`). */ async d_stash_list(only_configured_repo = false) { return await this.d_custom("stash list", only_configured_repo); } /** `Git-clean -dxf` on the discoverd repositories (`listD`). */ async d_clean(only_configured_repo = false) { return await this.d_custom("clean -dxf", only_configured_repo); } /** Export in a Yaml-file the list of discoverd repositories (`listD`). */ async d_export_yaml(yamlPath, commit_version = false) { let r_code = -1; if (yamlPath === "") { console.log(`ERR482: Error, the discoverDir cannot be an empty string`); return -1; } const repos = this.d_list(); const repos_info = await get_repos_info(repos); const fyaml = { repositories: {} }; for (const repo of repos_info) { let version = repo.branch; if (commit_version) { version = repo.commit; } fyaml.repositories[repo.localPath] = { type: "git", url: repo.url, version }; } const fstr = YAML.stringify(fyaml); try { await fse.outputFile(yamlPath, fstr); r_code = 0; } catch (error) { console.log(`ERR218: Error by writting the yaml-file ${yamlPath}!`); console.error(error); } console.log(`The yaml-file ${yamlPath} has been written!`); return r_code; } /** * Validate a yaml-file that could be imported later on. * * @param yamlPath the path to the yaml-file to be checked/validated. * @returns an integer-code. 0 if the validation is successful, negative otherwise. */ async validate_yaml(yamlPath) { if (yamlPath === "") { console.log(`ERR482: Error, the discoverDir cannot be an empty string`); return -1; } return await validate_yaml_external(yamlPath); } /** * Return a string with the three numbers (major, minor, patch) written in the package.json. * * @returns the string Major.Minor.Patch */ static version_short() { return vag_version_short; } }; // src/vag_cli.ts var cmd = { list: false, clone: false, checkout: false, verify: false, fetch: false, pull: false, push: false, branch: false, status: false, diff: false, log: false, remote: false, stash_list: false, clean: false, custom: false, export_yaml: false, validate_yaml: false, versions: false }; var argv = yargs(hideBin(process.argv)).scriptName("vag").usage("Usage: $0 <global-options> command <command-options>").example([ [ "$0 clone --importYaml repos.yml --importDir subRepos", "clone the git-repos listed in repos.yml in the directory subRepos" ], [ "$0 status --discoverDir=subRepos", "show the status of all discovered git-repos with the directory subRepos" ], [ "$0 custom --git_command 'log -u -n1'", "apply the cutom-command git-log to all discovered git-repos" ], [ "$0 export_yaml --yaml_path=myRepos.yml --commit_version=true", "export the discovered git-repos in the yaml-file myRepos.yml" ] ]).option("discoverDir", { alias: "d", type: "string", description: "directory-path for searching git-repositories.", default: "." }).option("deepSearch", { alias: "D", type: "boolean", description: "search further for git-repos within found git-repos.", default: true }).option("importYaml", { alias: "y", type: "string", description: "path to the yaml-file containing the list of repos.", default: "" }).option("importDir", { alias: "b", type: "string", description: "path to the directory where to clone the repos. If not specified, the directory of the yaml-file is used.", default: "" }).option("only_configured", { alias: "c", type: "boolean", description: "not on all discovered git-repos but only if in importYaml", default: false }).command("list", "print the lists of git-repositories", {}, () => { cmd.list = true; }).command("clone", "clone the git-repositories listed in the importYaml file", {}, () => { cmd.clone = true; }).command("checkout", "checkout the git-repos according to the importYaml file", {}, () => { cmd.checkout = true; }).command("verify", "verify if the discovered git-repos fit with the importYaml", {}, () => { cmd.verify = true; }).command("fetch", "git fetch --prune the discovered git-repositories", {}, () => { cmd.fetch = true; }).command("pull", "pull the discovered git-repositories", {}, () => { cmd.pull = true; }).command("push", "push the discovered git-repositories", {}, () => { cmd.push = true; }).command("branch", "show branch of the discovered git-repositories", {}, () => { cmd.branch = true; }).command("status", "show status of the discovered git-repositories", {}, () => { cmd.status = true; }).command("diff", "show diff of the discovered git-repositories", {}, () => { cmd.diff = true; }).command("log", "show log of the discovered git-repositories", {}, () => { cmd.log = true; }).command("remote", "show remote of the discovered git-repositories", {}, () => { cmd.remote = true; }).command("stash_list", "show stash_list of the discovered git-repositories", {}, () => { cmd.stash_list = true; }).command("clean", "git clean -dxf of the discovered git-repositories", {}, () => { cmd.clean = true; }).command( "custom", "git custom command for each of the discovered git-repos", { git_command: { type: "string", description: "the git-command to be apply", demandOption: true } }, () => { cmd.custom = true; } ).command( "export_yaml", "export the discovered git-repositories in a yaml-file", { yaml_path: { type: "string", description: "the path to the output yaml-file", demandOption: true }, commit_version: { type: "boolean", description: "Use commit-hash instead of branch-name for version", default: false } }, () => { cmd.export_yaml = true; } ).command( "validate_yaml", "validate the syntax of a yaml-file", { yaml_path: { type: "string", description: "the path to the output yaml-file", demandOption: true } }, () => { cmd.validate_yaml = true; } ).command("versions", "print the versions of vag", {}, () => { cmd.versions = true; }).strict().parseSync(); var vag = new Vag(argv.discoverDir, argv.deepSearch, argv.importYaml, argv.importDir); await vag.init(); function display_repo_list(repos) { for (const [idx, repo] of repos.entries()) { console.log(` ${idx + 1} : ${repo}`); } } if (cmd.list) { const d_list = vag.d_list(); const c_list = vag.c_list(); const cd_list = vag.cd_list(); const dnc_list = vag.dnc_list(); const cnd_list = vag.cnd_list(); console.log(`List-D : ${d_list.length} discovered git-repositories`); display_repo_list(d_list); console.log(`List-C : ${c_list.length} configured git-repositories`); display_repo_list(c_list); console.log(`List-CD : ${cd_list.length} configured and discovered git-repositories`); display_repo_list(cd_list); console.log(`List-DnC : ${dnc_list.length} discovered git-repositories but not configured`); display_repo_list(dnc_list); console.log(`List-CnD : ${cnd_list.length} configured git-repositories but not discovered`); display_repo_list(cnd_list); } if (cmd.clone) { await vag.c_clone(); } if (cmd.checkout) { await vag.cd_checkout(); } if (cmd.verify) { await vag.cd_verify(); } if (cmd.fetch) { await vag.d_fetch(argv.only_configured); } if (cmd.pull) { await vag.d_pull(argv.only_configured); } if (cmd.push) { await vag.d_push(argv.only_configured); } if (cmd.branch) { await vag.d_branch(argv.only_configured); } if (cmd.status) { await vag.d_status(argv.only_configured); } if (cmd.diff) { await vag.d_diff(argv.only_configured); } if (cmd.log) { await vag.d_log(argv.only_configured); } if (cmd.remote) { await vag.d_remote(argv.only_configured); } if (cmd.stash_list) { await vag.d_stash_list(argv.only_configured); } if (cmd.clean) { await vag.d_clean(argv.only_configured); } if (cmd.custom) { await vag.d_custom(argv.git_command, argv.only_configured); } if (cmd.export_yaml) { await vag.d_export_yaml(argv.yaml_path, argv.commit_version); } if (cmd.validate_yaml) { await vag.validate_yaml(argv.yaml_path); } if (cmd.versions) { console.log(`vag-version-short : ${Vag.version_short()}`); } //# sourceMappingURL=vag_cli.js.map