diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c5b87cf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to the Project + +We welcome contributions from the community to help improve the project. Whether you're interested in fixing bugs, adding new features, improving documentation, or providing recommendations, your contributions are valuable to us. + +## Ways to Contribute + +You can contribute to the project in the following ways: + +- **Create Issues**: Feel free to create issues for feature requests, bug fixes, documentation improvements, or any recommendations you may have. +- **Forks and Merge Requests**: Fork the repository, make your changes, and submit a merge request with a clear explanation of the changes you've made and why they're necessary. + +## Guidelines for Contributions + +When contributing to the project, please adhere to the following guidelines: + +- **Be Respectful**: Treat other contributors and users with respect and courtesy. +- **Follow Code of Conduct**: Follow the project's code of conduct to maintain a friendly and inclusive environment. +- **Provide Detailed Explanations**: Clearly explain the purpose of your contribution, whether it's a new feature, bug fix, documentation update, or recommendation. +- **Follow Coding Standards**: If you're contributing code, adhere to the project's coding standards and conventions. +- **Test Your Changes**: Ensure that your changes are thoroughly tested before submitting them. + +## Rules for Contributions + +To ensure a smooth contribution process, please keep the following rules in mind: + +- **License Awareness**: Be aware of and check the LICENSE file before contributing to understand the project's licensing terms. +- **Respect License Terms**: Ensure that your contributions comply with the project's license terms. +- **Avoid Plagiarism**: Do not plagiarize code or content from other sources. All contributions should be original work or properly attributed. + +## Get Started + +Ready to contribute? Start by creating an issue or fork the repository to begin making your changes. We appreciate your contributions and look forward to working with you to improve the project. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 397b9d1..10b0d6d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 NobleMajo +Copyright (c) 2024 Majo Richter (NobleMajo) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6c50a7 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Table of Contents +- [Table of Contents](#table-of-contents) +- [About](#about) +- [Key Features](#key-features) +- [Requirements](#requirements) +- [Getting started](#getting-started) +- [Technologies](#technologies) +- [License](#license) +- [Contributing](#contributing) +- [Disclaimer](#disclaimer) + +# About +HiveLib simplifies SSH2 connections via promise-based task execution on Linux servers with built-in server utilities and powerful command execution functions. + +HiveLib is a library designed to streamline SSH2 connections and task execution on Linux servers. It provides user-friendly promise-based functions for efficient server operations without the need for a client application. + +# Key Features +HiveLib offers the following key features: +- __All-Distributions__: SSH2 and SFTP operations for all Linux servers +- __Promisified__: Promise-based functions for ease of use +- __AbstractPackageManager__: Built-in abstract package manager with support for apt, dnf, and yum, with additional configurability +- __Exec__: Command execution utilities for event or promise-based error handling and output parsing, filtering, and mapping + +# Requirements +HiveLib requires the following server environments: +- **SSH2 server** +- **SFTP support** +- **Linux distribution** + +# Getting started + +```ts +//coming soon +import {} +``` + +# Technologies +HiveLib is built using the following technologies: +- **TypeScript** +- **Node.js** +- **SSH2** +- **SFTP** + +# License +HiveLib is licensed under the MIT license, providing users with flexibility and freedom to use and modify the software according to their needs. + +# Contributing +Contributions to HiveLib are welcome! +Interested users can refer to the guidelines provided in the CONTRIBUTING.md file to contribute to the project and help improve its functionality and features. + +# Disclaimer +HiveLib is provided without warranties. +Users are advised to review the accompanying license for more information on the terms of use and limitations of liability. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..e58615c --- /dev/null +++ b/package-lock.json @@ -0,0 +1,518 @@ +{ + "name": "hivelib", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hivelib", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "ssh2": "^1.15.0" + }, + "devDependencies": { + "@types/ssh2": "^1.15.0", + "nodemon": "^3.1.0", + "typescript": "^5.4.3" + } + }, + "node_modules/@types/node": { + "version": "18.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.31.tgz", + "integrity": "sha512-ArgCD39YpyyrtFKIqMDvjz79jto5fcI/SVUs2HwB+f0dAzq68yqOdyaSivLiLugSziTpNXLQrVb7RZFmdZzbhA==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/ssh2": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@types/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-YcT8jP5F8NzWeevWvcyrrLB3zcneVjzYY9ZDSMAMboI+2zR1qYWFhwsyOFVzT7Jorn67vqxC0FRiw8YyG9P1ww==", + "dev": true, + "dependencies": { + "@types/node": "^18.11.18" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buildcheck": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz", + "integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cpu-features": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz", + "integrity": "sha512-AKjgn2rP2yJyfbepsmLfiYcmtNn/2eUvocUyM/09yB0YDiz39HteK/5/T4Onf0pmdYDMgkBoGvRLvEguzyL7wQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.17.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nan": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", + "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==", + "optional": true + }, + "node_modules/nodemon": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.0.tgz", + "integrity": "sha512-xqlktYlDMCepBJd43ZQhjWwMw2obW/JRvkrLxq5RCNcuDDX1DbcPT+qT1IlIIdf+DhnWs90JpTMe+Y5KxOchvA==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nopt": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-1.0.10.tgz", + "integrity": "sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==", + "dev": true, + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ssh2": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz", + "integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.9", + "nan": "^2.18.0" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/touch": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.0.tgz", + "integrity": "sha512-WBx8Uy5TLtOSRtIq+M03/sKDrXCLHxwDcquSP2c43Le03/9serjQBIztjRz6FkJez9D/hleyAXTBGLwwZUw9lA==", + "dev": true, + "dependencies": { + "nopt": "~1.0.10" + }, + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, + "node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..650bd40 --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "hivelib", + "version": "0.0.1", + "description": "HiveLib simplifies SSH2 connections via promise-based task execution on Linux servers with built-in server utilities and powerful command execution functions", + "main": "dist/index.js", + "type": "module", + "scripts": { + "start": "node --enable-source-maps dist/index.js", + "build": "tsc", + "exec": "tsc && node --enable-source-maps dist/index.js", + "dev": "nodemon -w ./src --ext *.ts -x \"tsc && node --enable-source-maps dist/index.js\"" + }, + "keywords": [ + "lib", + "library", + "ssh2", + "sftp", + "typescript", + "auto", + "automation", + "orch", + "orchestrating", + "orchestration", + "configuring", + "configuration", + "managing", + "manage", + "promise", + "async", + "tool", + "open-source", + "hive", + "linux", + "ansible" + ], + "author": { + "name": "Majo Richter", + "alias": "NobleMajo", + "email": "majo@coreunit.net", + "url": "https://github.com/NobleMajo" + }, + "os": [ + "!win32" + ], + "homepage": "https://github.com/NobleMajo/hivelib", + "bugs": { + "url": "https://github.com/NobleMajo/hivelib/issues" + }, + "repository": { + "type": "git", + "url": "git@github.com:NobleMajo/hivelib.git" + }, + "license": "MIT", + "devDependencies": { + "typescript": "^5.4.3", + "@types/ssh2": "^1.15.0", + "nodemon": "^3.1.0" + }, + "dependencies": { + "ssh2": "^1.15.0" + } +} \ No newline at end of file diff --git a/src/HostHop.ts b/src/HostHop.ts new file mode 100644 index 0000000..f85e00b --- /dev/null +++ b/src/HostHop.ts @@ -0,0 +1,95 @@ +import { ClientChannel, Client as SshClient } from "ssh2"; +import { HopHostSettings, SshHostBaseSettings } from "./SshHostOptions.js"; + +export function createClient( + settings: HopHostSettings, +): Promise { + return new Promise((res, rej) => { + const hopClient = new SshClient() + + hopClient.on("ready", () => res(hopClient)) + hopClient.on("error", rej) + hopClient.connect({ + ...settings, + host: settings.sock ? undefined : settings.host, + port: settings.sock ? undefined : settings.port, + }) + }) +} + +export function createForward( + ssh: SshClient, + srcHost: string, + srcPort: number, + targetHost: string, + targetPort: number, +): Promise { + return new Promise((res, rej) => { + ssh.forwardOut( + srcHost, + srcPort, + targetHost, + targetPort, + (err, stream) => { + if (err) { + ssh.end() + rej(err) + return + } + + res(stream) + }) + }) +} + +export async function handleHops( + settings: SshHostBaseSettings, + defaultSrcHost: string = "127.0.0.1", + defaultSrcPort: number = 60022, +): Promise { + const hops = settings.hops + if ( + hops === undefined || + hops.length == 0 + ) { + return createClient(settings) + } + + let lastHop: SshClient = await createClient(hops[0]) + + for (let i = 1; i < hops.length; i++) { + const nextSetting = hops[i] + + const channel = await createForward( + lastHop, + defaultSrcHost, + defaultSrcPort, + nextSetting.host, + nextSetting.port, + ) + + lastHop = await createClient({ + ...nextSetting, + host: undefined as any, + port: undefined as any, + sock: channel, + }) + } + + const channel = await createForward( + lastHop, + defaultSrcHost, + defaultSrcPort, + settings.host, + settings.port, + ) + + const finalSshClient = await createClient({ + ...settings, + host: undefined as any, + port: undefined as any, + sock: channel, + }) + + return finalSshClient +} \ No newline at end of file diff --git a/src/SshExec.ts b/src/SshExec.ts new file mode 100644 index 0000000..af9952b --- /dev/null +++ b/src/SshExec.ts @@ -0,0 +1,372 @@ +import { ClientChannel, ExecOptions, Client as SshClient } from "ssh2"; +import { Readable } from "stream"; +import { Awaitable } from "./utils/base.js"; + +export interface ArrayOptions { + concurrency?: number; + signal?: AbortSignal; + encoding?: BufferEncoding +} + +export interface CmdChannelOptions extends ExecOptions { + pwd?: string, + timeoutMillis?: number, + sudo?: boolean, +} + +export interface CmdChannelSettings extends ExecOptions { + pwd: string, + timeoutMillis: number, + sudo: boolean, +} + +export const defaultCmdChannelSettings: CmdChannelSettings = { + pwd: "/", + timeoutMillis: -1, + sudo: false, +} + +export type CmdExecOptions = CmdChannelOptions & ChannelToPromiseOptions + +export interface SshChannelExit { + cmd: string, + out: string, + chunks: [boolean, string][], + anyErr: boolean, + anyStd: boolean, + code: number, + signal?: string, + dump?: string, + desc?: string +} + +export class SshChannelExitError extends Error { + constructor( + message: string, + public exit: SshChannelExit, + ) { + super(message) + } +} + +export type SshChannelToPromise = (options?: ChannelToPromiseOptions) => Promise + +export interface SshChannelExtras { + cmd: string, + timeout?: NodeJS.Timeout | undefined, + settings: CmdChannelSettings, + stdout: Readable, + toPromise: SshChannelToPromise +} + +export type SshChannel = Omit & SshChannelExtras + +export function toSshChannel( + channel: ClientChannel, + extras: SshChannelExtras, +): SshChannel { + for (const key of Object.keys(extras) as (keyof SshChannelExtras)[]) { + (channel as any)[key] = extras[key] + } + + return channel as any +} + +export async function execSshChannel( + sshClient: SshClient, + cmd: string, + options?: CmdChannelOptions +): Promise { + const settings: CmdChannelSettings = { + ...defaultCmdChannelSettings, + ...options, + } + + settings.env = { + ...settings.env, + PWD: settings.pwd, + } + + if (settings.sudo) { + cmd = "sudo " + cmd + } + + const baseChannel = await new Promise( + (res, rej) => sshClient.exec( + cmd, + settings, + (err, channel) => err ? rej(err) : res(channel) + ) + ) + + const channel = toSshChannel( + baseChannel, + { + cmd, + settings, + stdout: baseChannel.stdout, + toPromise: sshChannelToPromise( + baseChannel, + cmd, + ) + } + ) + + if ( + typeof settings.timeoutMillis == "number" && + settings.timeoutMillis > 0 + ) { + channel.timeout = setTimeout( + () => { + if (!channel.closed) { + channel.close("Timeout", settings.timeoutMillis) + } + }, + settings.timeoutMillis + ) + channel.once( + "close", + () => clearTimeout(channel.timeout) + ) + } + + return channel +} + +export type StreamDataMapper = ( + data: string, + err: boolean +) => Awaitable + +export type StreamDataFilter = ( + data: string, + err: boolean, + options?: Pick +) => Awaitable + +export interface ChannelToPromiseOptions { + mapOut?: StreamDataMapper, + mapStdOut?: StreamDataMapper, + mapErrOut?: StreamDataMapper, + filterOut?: StreamDataFilter, + filterStdOut?: StreamDataFilter, + filterErrOut?: StreamDataFilter, + filterOutOptions?: ArrayOptions, + filterStdOutOptions?: ArrayOptions, + filterErrOutOptions?: ArrayOptions, + throwOnOut?: boolean, + throwOnStdOut?: boolean, + throwOnErrOut?: boolean, + expectedExitCode?: number | number[], + +} + +export interface ChannelToPromiseSettings { + mapOut: StreamDataMapper | undefined, + mapStdOut: StreamDataMapper | undefined, + mapErrOut: StreamDataMapper | undefined, + filterOut: StreamDataFilter | undefined, + filterStdOut: StreamDataFilter | undefined, + filterErrOut: StreamDataFilter | undefined, + filterOutOptions: ArrayOptions | undefined, + filterStdOutOptions: ArrayOptions | undefined, + filterErrOutOptions: ArrayOptions | undefined, + throwOnOut: boolean | undefined, + throwOnStdOut: boolean, + throwOnErrOut: boolean, + expectedExitCode: number[], +} + +export const defaultChannelToPromiseSettings: ChannelToPromiseSettings = { + mapOut: undefined, + mapStdOut: undefined, + mapErrOut: undefined, + filterOut: undefined, + filterStdOut: undefined, + filterErrOut: undefined, + filterOutOptions: undefined, + filterStdOutOptions: undefined, + filterErrOutOptions: undefined, + throwOnOut: undefined, + throwOnStdOut: false, + throwOnErrOut: true, + expectedExitCode: [0], +} + + +export function sshChannelToPromise( + channel: ClientChannel, + cmd: string, +): SshChannelToPromise { + return ( + options?: ChannelToPromiseOptions + ) => new Promise((res, rej) => { + const settings: ChannelToPromiseSettings = { + ...defaultChannelToPromiseSettings, + ...options, + expectedExitCode: undefined as any, + } + + if (typeof settings.expectedExitCode == "number") { + settings.expectedExitCode = [settings.expectedExitCode] + } + + const chunks: [boolean, string][] = [] + let anyErr: boolean = false + let anyStd: boolean = false + let stdout: Readable = channel.stdout + let stderr: Readable = channel.stderr + + if (typeof settings == "object") { + if (settings.filterOut) { + settings.filterStdOut = settings.filterOut + settings.filterErrOut = settings.filterOut + } + if (settings.filterOutOptions) { + settings.filterStdOutOptions = settings.filterOutOptions + settings.filterErrOutOptions = settings.filterOutOptions + } + if (settings.throwOnOut) { + settings.throwOnStdOut = settings.throwOnOut + settings.throwOnErrOut = settings.throwOnOut + } + + if (settings.mapOut) { + settings.mapStdOut = settings.mapOut + settings.mapErrOut = settings.mapOut + } + + if (settings.filterStdOut) { + const filterStdOut = settings.filterStdOut + stdout.filter( + (data, options) => filterStdOut( + data, + false, + options + ), + settings.filterStdOutOptions + ) + } + if (settings.filterErrOut) { + const filterErrOut = settings.filterErrOut + stderr.filter( + (data, options) => filterErrOut( + data, + true, + options + ), + settings.filterErrOutOptions + ) + } + + if (settings.mapStdOut) { + const mapStdOut = settings.mapStdOut + stdout.on("data", (chunk) => { + chunk = mapStdOut(chunk, false) + if (typeof chunk == "string") { + chunks.push([false, "" + chunk]) + } + }) + } else { + stdout.on("data", (chunk) => { + chunks.push([false, "" + chunk]) + }) + } + + if (settings.mapErrOut) { + const mapErrOut = settings.mapErrOut + stderr.on("data", (chunk) => { + chunk = mapErrOut(chunk, true) + if (typeof chunk == "string") { + chunks.push([true, "" + chunk]) + } + }) + } else { + stderr.on("data", (chunk) => { + chunks.push([true, "" + chunk]) + }) + } + } else { + stdout.on("data", (chunk) => { + chunks.push([false, "" + chunk]) + }) + + stderr.on("data", (chunk) => { + chunks.push([true, "" + chunk]) + }) + } + + stdout.once("data", () => { + anyStd = true + }) + + stderr.once("data", () => { + anyErr = true + }) + + channel.once( + "error", + (err: Error) => { + rej(err) + if (!channel.closed) { + channel.close() + } + } + ) + + channel.once("exit", ( + code: number | null, + signal?: string, + dump?: string, + desc?: string, + ) => { + if (!channel.closed) { + channel.close() + } + + const exit: SshChannelExit = { + cmd, + code: code === null ? -1 : code, + signal, + dump, + desc, + chunks, + anyErr, + anyStd, + out: chunks.map((v) => v[1]).join("\n") + } + + if (options) { + if ( + options.throwOnErrOut && + anyErr + ) { + rej(new SshChannelExitError( + "Unexpected error stream output:\n " + + chunks.filter( + (chunks) => chunks[0] + ).join("\n "), + exit + )) + return + } + if ( + options.throwOnStdOut && + anyStd + ) { + rej(new SshChannelExitError( + "Unexpected standard stream output:\n " + + chunks.filter( + (chunks) => !chunks[0] + ).join("\n "), + exit + )) + return + + } + } + + res(exit) + }) + }) +} \ No newline at end of file diff --git a/src/SshHost.ts b/src/SshHost.ts new file mode 100644 index 0000000..4e31944 --- /dev/null +++ b/src/SshHost.ts @@ -0,0 +1,148 @@ +import { ClientErrorExtensions, SFTPWrapper, Client as SshClient } from "ssh2" +import { handleHops } from "./HostHop.js" +import { CmdChannelOptions, CmdExecOptions, SshChannel, SshChannelExit, execSshChannel } from "./SshExec.js" +import { SshHostOptions, SshHostSettings, loadSettings } from "./SshHostOptions.js" +import { OsRelease, fetchOsRelease } from "./essentials/OsRelease.js" +import { SFTPPromiseWrapper, createSFTPPromiseWrapper } from "./essentials/SftpPromiseWrapper.js" +import { AbstractPackageManager, getPm } from "./pm/PackageManager.js" +import { Awaitable } from "./utils/base.js" + +export class SshHost { + closeErr?: any + connected: boolean = false + ssh: SshClient = undefined as any + sftp: SFTPPromiseWrapper = undefined as any + release: OsRelease = undefined as any + + static async connect( + options: SshHostOptions, + ): Promise { + const host = new SshHost( + await loadSettings(options) + ) + await host.connect() + return host + } + + private constructor( + public settings: SshHostSettings + ) { } + + private errorDisconnect(err: any) { + this.connected = false + if (!this.closeErr) { + this.closeErr = err + } + this.ssh.destroy() + } + + private async connect(): Promise { + this.ssh = await handleHops(this.settings) + this.connected = true + + this.ssh.on("close", () => { + this.errorDisconnect( + new Error("Ssh2 client closed!") + ) + }) + + this.ssh.on("end", () => { + this.errorDisconnect( + new Error("Ssh2 client end!") + ) + }) + + this.ssh.on("timeout", () => { + this.errorDisconnect( + new Error("Ssh2 client connection timeout!") + ) + }) + + this.ssh.on("error", (err: Error & ClientErrorExtensions) => { + this.errorDisconnect( + err + ) + }) + + const sftpWrapper = await new Promise( + (res, rej) => this.ssh.sftp( + (err, sftpClient) => err ? rej(err) : res(sftpClient) + ) + ) + + this.sftp = createSFTPPromiseWrapper(sftpWrapper) + } + + close(): void { + if (this.connected) { + this.ssh.end() + } + this.connected = false + } + + execChannel( + cmd: string, + options?: CmdChannelOptions + ): Promise { + return execSshChannel( + this.ssh, + cmd, + options + ) + } + + async exec( + cmd: string, + options?: CmdExecOptions + ): Promise { + const channel = await execSshChannel( + this.ssh, + cmd, + options + ) + + return channel.toPromise( + options + ) + } + + async exists( + cmd: string + ): Promise { + if (cmd.includes(" ")) { + throw new Error("Command can not contain a space: '" + cmd + "'") + } + + const exit = await this.exec(cmd, { + expectedExitCode: [0, 1] + }) + + return exit.code == 0 + } + + cachedOsRelease: OsRelease | undefined + fetchOsRelease(): Awaitable { + if (this.cachedOsRelease) { + return this.cachedOsRelease + } + + return fetchOsRelease( + this + ).then((release) => { + return this.cachedOsRelease = release + }) + } + + cachedPackageManager: AbstractPackageManager | undefined + getPm(): Awaitable { + if (this.cachedPackageManager) { + return this.cachedPackageManager + } + + return getPm( + this + ).then((pm) => { + return this.cachedPackageManager = pm + }) + } +} diff --git a/src/SshHostOptions.ts b/src/SshHostOptions.ts new file mode 100644 index 0000000..e031739 --- /dev/null +++ b/src/SshHostOptions.ts @@ -0,0 +1,92 @@ +import { promises as afs } from "fs" +import { ConnectConfig } from "ssh2" +import { pathType } from "./utils/base.js" + +export type Ssh2SshOptions = Omit + +export interface SshHostBaseOptions { + host: string, + port?: number, + user?: string, + privateKey?: string, + privateKeyPath?: string, + passphrase?: string, + password?: string, + hops?: HopHostOptions[] | undefined, +} + +export interface SshHostBaseSettings { + host: string, + port: number, + user: string, + privateKey?: string, + privateKeyPath?: string, + passphrase?: string, + password?: string, + hops: HopHostSettings[] | undefined, +} + +export type SshHostOptions = SshHostBaseOptions & Ssh2SshOptions +export type SshHostTargetSettings = + Omit & + Pick & + Ssh2SshOptions +export type SshHostSettings = SshHostBaseSettings & Ssh2SshOptions + +export type HopHostOptions = Omit +export type HopHostSettings = Omit + +export const defaultSshHostSettings: SshHostSettings = { + host: undefined as any, + port: 22, + user: "root", + hops: undefined, + readyTimeout: 1000 * 8, +} + +export function completeTargetSettings( + options: SshHostOptions +): SshHostTargetSettings { + const ret: SshHostTargetSettings = { + ...defaultSshHostSettings, + ...options, + } + + return ret +} + + +export async function loadSettings( + options: SshHostOptions +): Promise { + const ret = completeTargetSettings(options) + + if (Array.isArray(options.hops)) { + ret.hops = await Promise.all( + options.hops.map( + loadSettings + ) + ) + } + + if ( + typeof options.privateKey != "string" && + typeof options.privateKeyPath == "string" + ) { + if ((await pathType(options.privateKeyPath)) != "FILE") { + throw new Error( + "Private key not exist at '" + + options.privateKeyPath + "'" + ) + } + + options.privateKey = await afs.readFile( + options.privateKeyPath, + { + encoding: "utf8" + } + ) + } + + return ret as SshHostSettings +} diff --git a/src/essentials/OsRelease.ts b/src/essentials/OsRelease.ts new file mode 100644 index 0000000..da11467 --- /dev/null +++ b/src/essentials/OsRelease.ts @@ -0,0 +1,79 @@ +import { SshHost } from "../SshHost.js"; + +export interface ReleaseMeta { + [key: string]: string +} + +export const unknownDistro = "unknown" + +export interface OsRelease { + distroName: string + distroVersion: string + meta: ReleaseMeta +} + +export const defaultOsRelease: OsRelease = { + distroName: unknownDistro, + distroVersion: unknownDistro, + meta: {} +} + +export async function fetchOsRelease( + sshHost: SshHost +): Promise { + let stats = await sshHost.sftp.readdir("/etc") + + stats = stats.filter((v) => v.filename.endsWith("-release")) + + if (stats.length == 0) { + throw new Error("No '/etc/*-release' file found") + } + + const meta: { + [key: string]: string + } = {} + + for (const stat of stats) { + const value = "" + await sshHost.sftp.readFile( + stat.path + "/" + stat.filename + ) + + for (const varLine of value.split("\n")) { + const i = varLine.indexOf("=") + + const key = varLine.slice(0, i) + let value = varLine.slice(i + 1) + + while ( + value.startsWith("\"") || + value.startsWith("'") + ) { + value = value.slice(1) + } + + while ( + value.endsWith("\"") || + value.endsWith("'") + ) { + value = value.slice(0, -1) + } + + meta[key] = value + } + } + + const name = ( + meta.NAME ?? meta.DISTRIB_ID ?? + meta.DISTRO_ID ?? meta.ID ?? "unknown" + ).toLowerCase() + const version = ( + meta.VERSION_ID ?? meta.DISTRIB_RELEASE ?? + meta.DISTRO_RELEASE ?? meta.RELEASE ?? "unknown" + ).toLowerCase() + + return { + distroName: name, + distroVersion: version, + meta: meta + } +} \ No newline at end of file diff --git a/src/essentials/SftpPromiseWrapper.ts b/src/essentials/SftpPromiseWrapper.ts new file mode 100644 index 0000000..23b1f2c --- /dev/null +++ b/src/essentials/SftpPromiseWrapper.ts @@ -0,0 +1,416 @@ +import { FileEntryWithStats, InputAttributes, OpenMode, ReadFileOptions, SFTPWrapper, Stats, TransferOptions, WriteFileOptions } from "ssh2" + +export interface FileStat { + path: string, + filename: string, + mode: number, + uid: number, + gid: number, + size: number, + atime: number, + mtime: number, + isDirectory: boolean + isFile: boolean + isBlockDevice: boolean + isCharacterDevice: boolean + isSymbolicLink: boolean + isFIFO: boolean + isSocket: boolean +} + +export function convertFileEntryState( + path: string, + stats: FileEntryWithStats +): FileStat { + return { + path: path, + filename: stats.filename, + mode: stats.attrs.mode, + uid: stats.attrs.uid, + gid: stats.attrs.gid, + size: stats.attrs.size, + atime: stats.attrs.atime, + mtime: stats.attrs.mtime, + isDirectory: stats.attrs.isDirectory(), + isFile: stats.attrs.isFile(), + isBlockDevice: stats.attrs.isBlockDevice(), + isCharacterDevice: stats.attrs.isCharacterDevice(), + isSymbolicLink: stats.attrs.isSymbolicLink(), + isFIFO: stats.attrs.isFIFO(), + isSocket: stats.attrs.isSocket(), + } +} + +export interface SFTPPromiseInterface { + /** + * (Client-only) + * Downloads a file at `remotePath` to `localPath` using parallel reads for faster throughput. + */ + fastGet(remotePath: string, localPath: string, options?: TransferOptions): Promise + + /** + * (Client-only) + * Uploads a file from `localPath` to `remotePath` using parallel reads for faster throughput. + */ + fastPut(localPath: string, remotePath: string, options?: TransferOptions): Promise + + /** + * (Client-only) + * Reads a file in memory and returns its contents + */ + readFile( + remotePath: string, + options?: ReadFileOptions | BufferEncoding, + ): Promise + + /** + * (Client-only) + * Writes data to a file + */ + writeFile(remotePath: string, data: string | Buffer, options?: WriteFileOptions | BufferEncoding): Promise + + /** + * (Client-only) + * Appends data to a file + */ + appendFile(remotePath: string, data: string | Buffer, options?: WriteFileOptions): Promise + + /** + * (Client-only) + * Opens a file `filename` for `mode` with optional `attributes`. + */ + open( + filename: string, + mode: number | OpenMode, + attributes: InputAttributes | string | number, + ): Promise + + /** + * (Client-only) + * Reads `length` bytes from the resource associated with `handle` starting at `position` + * and stores the bytes in `buffer` starting at `offset`. + */ + read( + handle: Buffer, + buffer: Buffer, + offset: number, + length: number, + position: number, + ): Promise<[number, Buffer, number]> + + /** + * (Client-only) + */ + write(handle: Buffer, buffer: Buffer, offset: number, length: number, position: number): Promise + + /** + * (Client-only) + * Retrieves attributes for the resource associated with `handle`. + */ + fstat(handle: Buffer): Promise + + /** + * (Client-only) + * Sets the attributes defined in `attributes` for the resource associated with `handle`. + */ + fsetstat(handle: Buffer, attributes: InputAttributes): Promise + + /** + * (Client-only) + * Sets the access time and modified time for the resource associated with `handle`. + */ + futimes(handle: Buffer, atime: number | Date, mtime: number | Date): Promise + + /** + * (Client-only) + * Sets the owner for the resource associated with `handle`. + */ + fchown(handle: Buffer, uid: number, gid: number): Promise + + /** + * (Client-only) + * Sets the mode for the resource associated with `handle`. + */ + fchmod(handle: Buffer, mode: number | string): Promise + + /** + * (Client-only) + * Opens a directory `path`. + */ + opendir(path: string): Promise + + /** + * (Client-only) + * Retrieves a directory listing. + */ + readdir(location: string | Buffer): Promise + + /** + * (Client-only) + * Removes the file/symlink at `path`. + */ + unlink(path: string): Promise + + /** + * (Client-only) + * Renames/moves `srcPath` to `destPath`. + */ + rename(srcPath: string, destPath: string): Promise + + /** + * (Client-only) + * Creates a new directory `path`. + */ + mkdir(path: string, attributes: InputAttributes): Promise + + /** + * (Client-only) + * Creates a new directory `path`. + */ + mkdir(path: string): Promise + + /** + * (Client-only) + * Removes the directory at `path`. + */ + rmdir(path: string): Promise + + /** + * (Client-only) + * Retrieves attributes for `path`. + */ + stat(path: string): Promise + + /** + * (Client-only) + * `path` exists. + */ + exists(path: string): Promise + + /** + * (Client-only) + * Retrieves attributes for `path`. If `path` is a symlink, the link itself is stat'ed + * instead of the resource it refers to. + */ + lstat(path: string): Promise + + /** + * (Client-only) + * Sets the attributes defined in `attributes` for `path`. + */ + setstat(path: string, attributes: InputAttributes): Promise + + /** + * (Client-only) + * Sets the access time and modified time for `path`. + */ + utimes(path: string, atime: number | Date, mtime: number | Date): Promise + + /** + * (Client-only) + * Sets the owner for `path`. + */ + chown(path: string, uid: number, gid: number): Promise + + /** + * (Client-only) + * Sets the mode for `path`. + */ + chmod(path: string, mode: number | string): Promise + + /** + * (Client-only) + * Retrieves the target for a symlink at `path`. + */ + readlink(path: string): Promise + + /** + * (Client-only) + * Creates a symlink at `linkPath` to `targetPath`. + */ + symlink(targetPath: string, linkPath: string): Promise + + /** + * (Client-only) + * Resolves `path` to an absolute path. + */ + realpath(path: string): Promise + + /** + * (Client-only, OpenSSH extension) + * Performs POSIX rename(3) from `srcPath` to `destPath`. + */ + ext_openssh_rename(srcPath: string, destPath: string): Promise + + /** + * (Client-only, OpenSSH extension) + * Performs POSIX statvfs(2) on `path`. + */ + ext_openssh_statvfs(path: string): Promise + + /** + * (Client-only, OpenSSH extension) + * Performs POSIX fstatvfs(2) on open handle `handle`. + */ + ext_openssh_fstatvfs(handle: Buffer): Promise + + /** + * (Client-only, OpenSSH extension) + * Performs POSIX link(2) to create a hard link to `targetPath` at `linkPath`. + */ + ext_openssh_hardlink(targetPath: string, linkPath: string): Promise + + /** + * (Client-only, OpenSSH extension) + * Performs POSIX fsync(3) on the open handle `handle`. + */ + ext_openssh_fsync(handle: Buffer): Promise + + /** + * (Client-only, OpenSSH extension) + * Similar to setstat(), but instead sets attributes on symlinks. + */ + ext_openssh_lsetstat(path: string, attrs: InputAttributes): Promise + ext_openssh_lsetstat(path: string): Promise + + /** + * (Client-only, OpenSSH extension) + * Similar to realpath(), but supports tilde-expansion, i.e. "~", "~/..." and "~user/...". These paths are expanded using shell-like rules. + */ + ext_openssh_expandPath(path: string): Promise + + /** + * (Client-only) + * Performs a remote file copy. If length is 0, then the server will read from srcHandle until EOF is reached. + */ + ext_copy_data( + handle: Buffer, + srcOffset: number, + len: number, + dstHandle: Buffer, + dstOffset: number, + ): Promise +} + +const voidMethods = [ + "fastGet", + "fastPut", + "writeFile", + "appendFile", + "write", + "fsetstat", + "futimes", + "fchown", + "fchmod", + "unlink", + "rename", + "mkdir", + "mkdir", + "rmdir", + "setstat", + "utimes", + "chown", + "chmod", + "symlink", + "ext_openssh_rename", + "ext_openssh_hardlink", + "ext_openssh_lsetstat", + "ext_openssh_lsetstat", +] + +const bufferMethods = [ + "readFile", + "open", + "opendir", +] + +const stringMethods = [ + "readlink", + "realpath", + "ext_openssh_expandPath", +] + +const statsMethods = [ + "fstat", + "lstat", + "stat", +] + +const anyMethods = [ + "ext_openssh_statvfs", + "ext_openssh_fstatvfs", + "ext_openssh_fsync", +] + +const booleanMethods = [ + "exists", +] + +const singleValueErrorCallbacks = [ + ...bufferMethods, + ...stringMethods, + ...statsMethods, + ...anyMethods, + ...booleanMethods, +] + +const fsStatsMethods = [ + "readdir", +] + +const otherMethods = [ + "read" +] + + +export type SFTPPromiseWrapper = SFTPPromiseInterface & Omit + +export function createSFTPPromiseWrapper( + sourceWrapper: SFTPWrapper +): SFTPPromiseWrapper { + const ret: any = sourceWrapper + + for (const voidMethod of voidMethods) { + const altName = voidMethod + "2" + ret[altName] = ret[voidMethod] + ret[voidMethod] = (...params: any[]) => new Promise( + (res) => ret[altName]( + ...params, + res + ) + ) + } + + for (const bufferMethod of singleValueErrorCallbacks) { + const altName = bufferMethod + "2" + ret[altName] = ret[bufferMethod] + ret[bufferMethod] = (...params: any[]) => new Promise( + (res, rej) => ret[altName]( + ...params, + (err: any, value: any) => err ? rej(err) : res(value) + ) + ) + } + + ret.readdir2 = ret.readdir + ret.readdir = (...params: any[]) => new Promise( + (res, rej) => ret.readdir( + ...params, + (err: any, value: FileEntryWithStats[]) => err ? rej(err) : res( + value.map( + (v) => convertFileEntryState(params[0], v) + ) + ) + ) + ) + + ret.read2 = ret.read + ret.read = (...params: any[]) => new Promise<[number, Buffer, number]>( + (res, rej) => ret.read2( + ...params, + (err: Error | undefined, read: number, buf: Buffer, pos: number) => err ? rej(err) : res([read, buf, pos]) + ) + ) + + return ret +} \ No newline at end of file diff --git a/src/essentials/User.ts b/src/essentials/User.ts new file mode 100644 index 0000000..519d8f6 --- /dev/null +++ b/src/essentials/User.ts @@ -0,0 +1,178 @@ +import { SshHost } from "../SshHost.js" +import { filterEmpty, trimAll } from "../utils/base.js" + +export async function isSudoer( + sshHost: SshHost, + user?: string +): Promise { + if (!user) { + user = sshHost.settings.user + } + + if (!(await sshHost.exists("sudo"))) { + throw new Error( + "Cant check if '" + user + "' is sudoer if sudo is not installed" + ) + } + + if (user.includes(" ")) { + throw new Error("User name '" + user + "' includes space") + } + + return sshHost.exec( + "sudo -l -U " + user, + ).then(async (v) => { + const lines = filterEmpty( + v.out.split("\n") + ) + for (const line of lines) { + if ( + trimAll(line).startsWith( + "User " + user + " is not allowed to run sudo" + ) + ) { + return false + } + } + return true + }) +} + +export async function listUsers( + sshHost: SshHost, +): Promise { + return sshHost.exec( + "getent passwd", + ).then((v) => { + return filterEmpty( + v.out.split("\n") + .map( + (v) => v.split(":")[0] + ) + ) + }) +} + +export async function listUserInGroups( + sshHost: SshHost, + user?: string +): Promise { + if (!user) { + user = sshHost.settings.user + } + + if (user.includes(" ")) { + throw new Error("User name '" + user + "' includes space") + } + + return sshHost.exec( + "groups " + user, + ).then((v) => { + let raw = trimAll(v.out) + + return filterEmpty( + raw.split(":")[1] + .split(" ") + ) + }) +} + +export async function isUserInGroup( + sshHost: SshHost, + group: string, + user?: string +): Promise { + const groups = await listUserInGroups( + sshHost, + user, + ) + + return groups.includes(group) +} + +export async function addUserToGroup( + sshHost: SshHost, + group: string, + user?: string +): Promise { + if (!user) { + user = sshHost.settings.user + } + + if (user.includes(" ")) { + throw new Error("User name '" + user + "' includes space") + } + if (group.includes(" ")) { + throw new Error("Group name '" + group + "' includes space") + } + + return sshHost.exec( + "gpasswd -a " + user + " " + group, + ).then() +} + +export async function removeUserFromGroup( + sshHost: SshHost, + group: string, + user?: string +): Promise { + if (!user) { + user = sshHost.settings.user + } + + if (user.includes(" ")) { + throw new Error("User name '" + user + "' includes space") + } + if (group.includes(" ")) { + throw new Error("Group name '" + group + "' includes space") + } + + return sshHost.exec( + "gpasswd -d " + user + " " + group, + ).then() +} + +export async function createGroup( + sshHost: SshHost, + group: string, + gid?: number, +): Promise { + if (group.includes(" ")) { + throw new Error("Group name '" + group + "' includes space") + } + + return sshHost.exec( + "groupadd" + (typeof gid == "number" ? " -g " + gid : "") + + " " + group, + ).then() +} + +export async function renameGroup( + sshHost: SshHost, + group: string, + newName: string, +): Promise { + if (group.includes(" ")) { + throw new Error("Group name '" + group + "' includes space") + } + if (newName.includes(" ")) { + throw new Error("New group name '" + newName + "' includes space") + } + + return sshHost.exec( + "groupmod -n " + newName + " " + group, + ).then() +} + +export async function deleteGroup( + sshHost: SshHost, + group: string, +): Promise { + if (group.includes(" ")) { + throw new Error("Group name '" + group + "' includes space") + } + + return sshHost.exec( + "groupdel " + group, + ).then() +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ab38672 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ + +export * from "./HostHop.js" +export * from "./SshExec.js" +export * from "./SshHost.js" +export * from "./SshHostOptions.js" +export * from "./essentials/SftpPromiseWrapper.js" +export * from "./pm/PackageManager.js" + +import { SshHost } from "./SshHost.js" + +export default SshHost \ No newline at end of file diff --git a/src/pm/PackageManager.ts b/src/pm/PackageManager.ts new file mode 100644 index 0000000..5961d15 --- /dev/null +++ b/src/pm/PackageManager.ts @@ -0,0 +1,128 @@ +import { SshHost } from "../SshHost.js" +import { Awaitable } from "../utils/base.js" +import { initAptPm } from "./apt.js" +import { initDnfPm } from "./dnf.js" +import { initYumPm } from "./yum.js" + +export interface AbstractPackage { + name: string, + version: string, + description?: string, + fields: { + [key: string]: string + } +} + +export interface AbstractPackageManager { + type: string + sshHost: SshHost + + //### cache + /** + * @description Update package source cache + */ + updateCache(): Promise + /** + * @description Clear package source cache + */ + clearCache(): Promise + + //### edit + /** + * @description Install defined packages + */ + install(...pkgs: string[]): Promise + /** + * @description Uninstall defined packages + */ + uninstall(...pkgs: string[]): Promise + + //### maintenance + /** + * @description Upgrades all upgradable packages + */ + upgradeAll(): Promise + /** + * @description Uninstall unused packages + */ + uninstallUnused(): Promise + + //### get + /** + * @description List of installed packages + */ + list(): Promise + /** + * @description List upgradable packages + */ + upgradable(): Promise + /** + * @description Returns a package description + */ + describe(pkg: string): Promise +} + +export type PmInit = ( + sshHost: SshHost +) => Awaitable + +export type PmChecker = ( + sshHost: SshHost, +) => Awaitable + +export const pmChecker: PmChecker[] = [] + +export async function getPm( + sshHost: SshHost +): Promise { + for (const pmCheck of pmChecker) { + const pm = await pmCheck( + sshHost, + ) + if (pm) { + return pm + } + } + + throw new Error( + "No package manager found for:\n" + + " " + sshHost.settings.user + "@" + + sshHost.settings.host + ":" + sshHost.settings.port + ) +} + +export function registerDefaultPmMapperChecker(): void { + pmChecker.push( + async (sshHost) => { + if ( + await sshHost.exists("apt") && + await sshHost.exists("apt-get") + ) { + return initAptPm(sshHost) + } + } + ) + + pmChecker.push( + async (sshHost) => { + if ( + await sshHost.exists("dnf") + ) { + return initDnfPm(sshHost) + } + } + ) + + pmChecker.push( + async (sshHost) => { + if ( + await sshHost.exists("yum") + ) { + return initYumPm(sshHost) + } + } + ) +} + +registerDefaultPmMapperChecker() + diff --git a/src/pm/apt.ts b/src/pm/apt.ts new file mode 100644 index 0000000..c81014e --- /dev/null +++ b/src/pm/apt.ts @@ -0,0 +1,212 @@ +import { SshChannelExit, StreamDataMapper } from "../SshExec.js" +import { SshHost } from "../SshHost.js" +import { Awaitable, trimAll } from "../utils/base.js" +import { AbstractPackage, AbstractPackageManager, PmInit } from "./PackageManager.js" + +export const aptEnv = { + "LANG": "en_US.UTF-8", + "DEBIAN_FRONTEND": "noninteractive", +} + +export const ignoredErrMsgs: string[] = [ + "debconf: unable to initialize frontend", + "warning" +] +export const ignoreMessageFilter: StreamDataMapper = ( + data: string +) => { + let data2 = trimAll(data).toLowerCase() + for (const msg of ignoredErrMsgs) { + if ( + data2.startsWith(msg) || + data2.endsWith(msg) || + data2.includes(msg) + ) { + return undefined + } + } + return data +} + +export const parsePackageList = ( + exit: SshChannelExit +): string[] => { + if (!exit.out.includes("Listing...")) { + throw new Error( + "Cmd out of '" + exit.cmd + "' not includes 'Listing...':\n " + + exit.out.split("\n").join("\n ") + ) + } + + return exit.out.split("\n") + .filter((v) => v.includes("/")) + .map((v) => v.split("/")[0]) + .map(trimAll) + .filter((v) => v.length != 0) +} + +export const parsePackageDescription = ( + exit: SshChannelExit +): AbstractPackage => { + const fieldLines = exit.out + .split("\n") + .filter((v) => v.includes(": ")) + + const fields: { + [key: string]: string + } = {} + + for (const fieldLine of fieldLines) { + const firstPos = fieldLine.indexOf(": ") + + const key = trimAll( + fieldLine.slice(0, firstPos) + ).toLowerCase() + const value = trimAll( + fieldLine.slice(firstPos + 2) + ) + + if ( + key.length == 0 || + value.length == 0 + ) { + continue + } + + fields[key] = value + } + + + if (!fields["package"]) { + throw new Error( + "'package' field in '" + exit.cmd + "' is missing:\n" + + JSON.stringify(fields) + ) + } + + if (!fields["version"]) { + throw new Error( + "'version' field in '" + exit.cmd + "' is missing:\n" + + JSON.stringify(fields) + ) + } + + return { + name: fields["package"], + version: fields["version"], + description: fields["description"], + fields: fields + } +} + +export const initAptPm: PmInit = ( + sshHost: SshHost, + cmdTimeoutMillis?: number | undefined +): Awaitable => { + return { + type: "apt", + sshHost, + + //### cache + updateCache: async () => { + return sshHost.exec( + "apt-get update", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then() + }, + clearCache: async () => { + return sshHost.exec( + "apt-get clean", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then() + }, + + //### edit + install: (...pkgs: string[]) => + sshHost.exec( + "apt-get install -o Dpkg::Options::=\"--force-confnew\" -y " + + pkgs.join(" "), + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then(), + uninstall: (...pkgs: string[]) => + sshHost.exec( + "apt-get purge -y --allow-remove-essential " + + pkgs.join(" "), + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then(), + + //### maintenance + upgradeAll: () => + sshHost.exec( + "apt-get full-upgrade -o Dpkg::Options::=\"--force-confnew\" -y", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then(), + uninstallUnused: () => + sshHost.exec( + "apt-get autoremove -y", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then(), + + //### get + list: () => + sshHost.exec( + "apt list --installed", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then(parsePackageList), + upgradable: () => + sshHost.exec( + "apt list --upgradable", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then(parsePackageList), + describe: (pkg: string) => + sshHost.exec( + "apt show " + pkg, + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreMessageFilter, + env: aptEnv, + } + ).then(parsePackageDescription), + } +} \ No newline at end of file diff --git a/src/pm/dnf.ts b/src/pm/dnf.ts new file mode 100644 index 0000000..2213e5f --- /dev/null +++ b/src/pm/dnf.ts @@ -0,0 +1,175 @@ +import { SshChannelExit, StreamDataMapper } from "../SshExec.js" +import { SshHost } from "../SshHost.js" +import { Awaitable, filterEmpty } from "../utils/base.js" +import { AbstractPackage, AbstractPackageManager, PmInit } from "./PackageManager.js" + +export const dnfEnv = { + LANG: "en_US.UTF-8" +} + +export const ignoreDnfMessages: StreamDataMapper = ( + data: string +) => { + const loweredData = data.toLowerCase() + if ( + loweredData.includes("transaction completed") || + loweredData.includes("base") || + loweredData.includes("cleaning up") || + loweredData.startsWith("warning:") + ) { + return undefined + } + return data +} + +export const parseDnfList = ( + exit: SshChannelExit +): string[] => { + const trimmedLines = filterEmpty( + exit.out.split("\n") + ) + + const packages = trimmedLines.slice(1).map((line) => line.split(/\s+/)[0]) + return packages +} + +export const parseDnfDescription = ( + exit: SshChannelExit +): AbstractPackage => { + const infoLines = exit.out.split("\n").filter((line) => line.includes(":")) + + const fields: { [key: string]: string } = {} + for (const infoLine of infoLines) { + const [key, value] = infoLine.trim().split(":") + fields[key.toLowerCase()] = value.trim() + } + + if (!fields.hasOwnProperty("name") || !fields.hasOwnProperty("version")) { + throw new Error( + "Required fields 'name' or 'version' missing in DNF package info" + ) + } + + return { + name: fields.name!, + version: fields.version!, + description: fields.description || "", + fields, + } +} + +export const initDnfPm: PmInit = ( + sshHost: SshHost, + cmdTimeoutMillis?: number | undefined, +): Awaitable => { + return { + type: "dnf", + sshHost, + + //### cache + updateCache: async () => { + return sshHost.exec( + 'dnf makecache', + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + ).then() + }, + clearCache: async () => { + return sshHost.exec( + 'dnf clean all', + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + ).then() + }, + + //### edit + install: (...pkgs: string[]) => + sshHost.exec( + "dnf install -y " + pkgs.join(" "), + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + ).then(), + uninstall: (...pkgs: string[]) => + sshHost.exec( + "dnf remove - y " + pkgs.join(" "), + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + ).then(), + + //### maintenance + upgradeAll: () => + sshHost.exec( + "dnf upgrade -y", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + ).then(), + uninstallUnused: () => + sshHost.exec( + "dnf autoremove -y", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + ).then(), + + //### get + list: () => + sshHost.exec( + "dnf list installed", + + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + + ).then(parseDnfList), + upgradable: () => + sshHost.exec( + "dnf list updates", + + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + + ).then(parseDnfList), + describe: (pkg: string) => + sshHost.exec( + "dnf info ${ pkg }", + + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreDnfMessages, + env: dnfEnv, + } + + ).then(parseDnfDescription), + } +} \ No newline at end of file diff --git a/src/pm/yum.ts b/src/pm/yum.ts new file mode 100644 index 0000000..e173a20 --- /dev/null +++ b/src/pm/yum.ts @@ -0,0 +1,162 @@ +import { SshChannelExit, StreamDataMapper } from "../SshExec.js" +import { SshHost } from "../SshHost.js" +import { Awaitable, filterEmpty } from "../utils/base.js" +import { AbstractPackage, AbstractPackageManager, PmInit } from "./PackageManager.js" + +export const yumEnv = { + LANG: "en_US.UTF-8" +} + +export const ignoreYumMessages: StreamDataMapper = (data: string) => { + const loweredData = data.toLowerCase() + if ( + loweredData.includes("transaction completed") || + loweredData.includes("base") || + loweredData.includes("cleaning up") || + loweredData.startsWith("warning:") + ) { + return undefined + } + return data +} + +export const parseYumList = (exit: SshChannelExit): string[] => { + const trimmedLines = filterEmpty( + exit.out.split("\n") + ) + const packages = trimmedLines.slice(1).map((line) => line.split(/\s+/)[0]) + return packages +} + +export const parseYumDescription = (exit: SshChannelExit): AbstractPackage => { + const infoLines = exit.out.split("\n").filter((line) => line.includes(":")) + + const fields: { [key: string]: string } = {} + for (const infoLine of infoLines) { + const [key, value] = infoLine.trim().split(":") + fields[key.toLowerCase()] = value.trim() + } + + if (!fields.hasOwnProperty("name") || !fields.hasOwnProperty("version")) { + throw new Error( + "Required fields 'name' or 'version' missing in Yum package info" + ) + } + + return { + name: fields.name!, + version: fields.version!, + description: fields.description || "", + fields, + } +} + +export const initYumPm: PmInit = ( + sshHost: SshHost, + cmdTimeoutMillis?: number | undefined, +): Awaitable => { + return { + type: "yum", + sshHost, + + //### cache + updateCache: async () => { + return sshHost.exec( + "yum makecache", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then() + }, + clearCache: async () => { + return sshHost.exec( + "yum clean all", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then() + }, + + //### edit + install: (...pkgs: string[]) => + sshHost.exec( + "yum install -y " + pkgs.join(" "), + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then(), + uninstall: (...pkgs: string[]) => + sshHost.exec( + "yum remove -y " + pkgs.join(" "), + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then(), + + //### maintenance + upgradeAll: () => + sshHost.exec( + "yum upgrade -y", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then(), + uninstallUnused: () => + sshHost.exec( + "yum autoremove -y", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then(), + + //### get + list: () => + sshHost.exec( + "yum list installed", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then(parseYumList), + upgradable: () => + sshHost.exec( + "yum list updates", + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then(parseYumList), + describe: (pkg: string) => + sshHost.exec( + "yum info " + pkg, + { + sudo: true, + timeoutMillis: cmdTimeoutMillis, + mapErrOut: ignoreYumMessages, + env: yumEnv, + } + ).then(parseYumDescription), + } +} diff --git a/src/utils/base.ts b/src/utils/base.ts new file mode 100644 index 0000000..98539eb --- /dev/null +++ b/src/utils/base.ts @@ -0,0 +1,95 @@ +import { promises as afs } from "fs" + +export type NoInfer = [T][T extends any ? 0 : never] + +export type BaseType = "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function" + +export type Awaitable = T | Promise + +export type JsonBaseType = string | number | boolean | null | undefined +export type JsonType = JsonHolder | JsonBaseType +export type JsonHolder = JsonObject | JsonArray +export type JsonArray = JsonType[] +export interface JsonObject { + [key: string]: JsonType +} + +export type FileType = "file" | "dir" | "none" +export const getFileType = async ( + path: string +): Promise => { + try { + const stat = await afs.stat(path) + if (stat.isFile()) { + return "file" + } else if (stat.isDirectory()) { + return "dir" + } + } catch (err) { + } + return "none" +} + +export function isOdd( + value: number +): boolean { + return (value % 2) === 1 +} + +export type PathType = "DIR" | "FILE" | "NONE" + +export async function pathType( + path: string, +): Promise { + try { + const stat = await afs.stat(path) + + if (stat.isFile()) { + return "FILE" + } else if (stat.isDirectory()) { + return "DIR" + } else { + return "NONE" + } + + } catch (err: Error | any) { + if ( + err instanceof Error && + (err as any).errno === -2 && + (err as any).code === "ENOENT" + ) { + return "NONE" + } + + throw err + } +} + +export function trimAll( + value: string +): string { + while ( + value.startsWith("\n") || + value.startsWith("\t") || + value.startsWith("'") + ) { + value = value.slice(1) + } + + while ( + value.endsWith("\n") || + value.endsWith("\t") || + value.endsWith("'") + ) { + value = value.slice(0, -1) + } + return value +} + +export function filterEmpty( + arr: string[] +): string[] { + return arr + .map(trimAll) + .filter((v) => v.length != 0) +} \ No newline at end of file diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..debfaa4 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,590 @@ + +export const logNames = [ + "Fatal", "Error", "Warning", "Info", "Trace", "Debug", +] as const +export type LogName = typeof logNames[number] + +export const logShorts = [ + "FAT", "ERR", "WAR", "INF", "TRA", "DEB", +] as const +export type LogShort = typeof logShorts[number] + +export const customLogType: { + [level: number]: { + name: string, + short: string, + } +} = {} + +export const fatalLogType: LogType = getLogTypeByLevel(0) +export const errorLogType: LogType = getLogTypeByLevel(4) +export const warningLogType: LogType = getLogTypeByLevel(8) +export const infoLogType: LogType = getLogTypeByLevel(12) +export const traceLogType: LogType = getLogTypeByLevel(16) +export const debugLogType: LogType = getLogTypeByLevel(20) + +export interface LogType { + name: string, + short: string, + level: number, +} + +export function getLogTypeByLevel( + level: number +): LogType { + if ( + level == 0 || + level == 4 || + level == 8 || + level == 12 || + level == 16 || + level == 20 + ) { + return { + level: level, + name: logNames[level / 4], + short: logShorts[level / 4] + } + } + + if (!customLogType[level]) { + return { + name: customLogType[level].name, + short: (customLogType[level].short) + .toUpperCase() + .slice(0, 3), + level: level, + } + } + + return { + level: level, + name: "Unknown", + short: "UNK", + } + + + +} + +export interface Log { + time: number, + type: LogType, + value: string, + area?: string, +} + +export type LogParser = (log: Log) => string + +export function defaultLogParser(log: Log): string { + return getLogTimestemp(log.time) + + "|" + log.type.short + + "|" + ( + log.area ? + log.area + "| " : + " " + ) + ( + log.value + .split("\n") + .join("\n ") + ) + ( + log.value.includes("\n") ? + "\n" : + "" + ) +} + +export function getLogTimestemp(date: Date | number): string { + if (typeof date == "number") { + date = new Date(date) + } + + let year = date.getFullYear() + let month = ('0' + (date.getMonth() + 1)).slice(-2) + let day = ('0' + date.getDate()).slice(-2) + let hours = ('0' + date.getHours()).slice(-2) + let minutes = ('0' + date.getMinutes()).slice(-2) + let seconds = ('0' + date.getSeconds()).slice(-2) + + return `${year}.${month}.${day}-${hours}:${minutes}:${seconds}` +} + +export type PipeTargetFunc = ((log: Log) => any) +export interface PipeTargetObject { + addLog: (log: Log) => any +} + +export type PipeTarget = PipeTargetFunc | PipeTargetObject + +export interface LogRewriteOptions { + isArea?: string | string[], + notArea?: string | string[], + startsWithArea?: string | string[], + newAreaPrefix?: string, + justWithoutArea?: boolean, + justWithArea?: boolean, + minLogLevel?: number, + maxLogLevel?: number, +} + +export interface LogRewriteSettings { + isArea: string[] | undefined, + notArea: string[] | undefined, + startsWithArea: string[] | undefined, + newAreaPrefix: string | undefined, + justWithoutArea?: boolean, + justWithArea?: boolean, + minLogLevel?: number, + maxLogLevel?: number, +} + +export class Logger { + pipeTargets: PipeTarget[] | undefined + + static StdOut: Logger = new Logger( + (log: Log): void => { + console.info(defaultLogParser(log)) + } + ) + + static StdStreams: Logger = new Logger( + (log: Log): void => { + if (log.type.level < 10) { + console.error(defaultLogParser(log)) + } else { + console.info(defaultLogParser(log)) + } + } + ) + + constructor( + pipeTargets: PipeTarget | PipeTarget[] | undefined = undefined, + public prefix: string | undefined = "", + public logs: Log[] = [], + ) { + if (pipeTargets) { + if (Array.isArray(pipeTargets)) { + this.pipeTargets = pipeTargets + } else { + this.pipeTargets = [pipeTargets] + } + for (const pipeTarget of this.pipeTargets) { + if ( + !pipeTarget || + pipeTarget == null || + ( + typeof pipeTarget != "function" && + typeof pipeTarget != "object" + ) + ) { + throw new Error( + "Pipe target need to be an object with log method or a calback function" + ) + } else if (pipeTarget == this) { + throw new Error("Cant select a logger as its own pipe target") + } + } + } + } + + rewrite = ( + options: LogRewriteOptions + ): PipeTargetFunc => { + const settings: LogRewriteSettings = { + ...options, + isArea: !options.isArea ? + undefined : + Array.isArray(options.isArea) ? + options.isArea : + [options.isArea], + notArea: !options.notArea ? + undefined : + Array.isArray(options.notArea) ? + options.notArea : + [options.notArea], + startsWithArea: !options.startsWithArea ? + undefined : + Array.isArray(options.startsWithArea) ? + options.startsWithArea : + [options.startsWithArea], + newAreaPrefix: options.newAreaPrefix ? + parseArea(options.newAreaPrefix) : + undefined, + } + + return (log) => { + if (log.area) { + if (settings.justWithoutArea == true) { + return + } + + if ( + settings.notArea && + settings.notArea.includes(log.area) + ) { + return + } + + if ( + settings.isArea && + !settings.isArea.includes(log.area) + ) { + return + } + + if ( + settings.startsWithArea + ) { + let found: boolean = false + for (const startsWithArea of settings.startsWithArea) { + if (log.area.startsWith(startsWithArea)) { + found = true + break + } + } + if (!found) { + return + } + } + } else if ( + settings.justWithArea == true || + ( + settings.isArea && + settings.isArea.length > 0 || + settings.startsWithArea && + settings.startsWithArea.length > 0 + ) + ) { + return + } + + if ( + settings.maxLogLevel && + log.type.level > settings.maxLogLevel + ) { + return + } + + if ( + settings.minLogLevel && + log.type.level < settings.minLogLevel + ) { + return + } + + this.addLog({ + ...log, + area: log.area ? ( + settings.newAreaPrefix ? + settings.newAreaPrefix + "-" + log.area : + log.area + ) : ( + settings.newAreaPrefix ? + settings.newAreaPrefix : + undefined + ) + }) + } + } + + pipe( + pipeTarget: PipeTarget, + justNewLogs: boolean = false + ): void { + if ( + !pipeTarget || + pipeTarget == null || + ( + typeof pipeTarget != "function" && + typeof pipeTarget != "object" + ) + ) { + throw new Error( + "Pipe target need to be an object with log method or a calback function" + ) + } else if (pipeTarget == this) { + throw new Error("Cant select a logger as its own pipe target") + } + + if (!justNewLogs) { + for (const log of this.logs) { + if (typeof pipeTarget == "function") { + pipeTarget(log) + } else { + pipeTarget.addLog(log) + } + } + } + + if (!this.pipeTargets) { + this.pipeTargets = [] + } + + this.pipeTargets.push(pipeTarget) + } + + unpipe( + pipeTarget: PipeTarget, + ): void { + if ( + !this.pipeTargets || + pipeTarget == this + ) { + return + } + + if ( + !pipeTarget || + pipeTarget == null || + ( + typeof pipeTarget != "function" && + typeof pipeTarget != "object" + ) + ) { + throw new Error( + "Pipe target need to be an object with log method or a calback function" + ) + } + + if (typeof pipeTarget == "function") { + const pipeTargetString = "" + pipeTarget + + this.pipeTargets = this.pipeTargets.filter( + (v) => { + if (typeof v == "function") { + return "" + v != pipeTargetString + } + return true + } + ) + } else { + this.pipeTargets = this.pipeTargets.filter( + (v) => { + if (typeof v == "object") { + return v != pipeTarget + } + return true + } + ) + } + + if (this.pipeTargets.length == 0) { + this.pipeTargets = undefined + } + } + + unpipeAll(): void { + this.pipeTargets = undefined + } + + clear(): void { + this.logs = [] + } + + fatal( + value: string, + area?: string, + ): void { + this.log( + fatalLogType, + value, + area + ) + } + + error( + value: string, + area?: string, + ): void { + this.log( + errorLogType, + value, + area + ) + } + + warn( + value: string, + area?: string, + ): void { + this.log( + warningLogType, + value, + area + ) + } + + info( + value: string, + area?: string, + ): void { + this.log( + infoLogType, + value, + area + ) + } + + trace( + value: string, + area?: string, + ): void { + this.log( + traceLogType, + value, + area + ) + } + + debug( + value: string, + area?: string, + ): void { + this.log( + debugLogType, + value, + area + ) + } + + log( + level: number | LogType, + value: string, + area?: string, + ): void { + this.addLog({ + type: typeof level == "number" ? + getLogTypeByLevel(level) : + level, + value: ( + this.prefix != undefined ? + this.prefix + value : + value + ), + area: area ? + parseArea(area) : + undefined, + time: Date.now(), + }) + } + + addLog( + log: Log, + ): void { + this.logs.push(log) + + if (this.pipeTargets) { + for (const pipeTarget of this.pipeTargets) { + if (typeof pipeTarget == "function") { + pipeTarget(log) + } else { + pipeTarget.addLog(log) + } + } + } + } + + getAbove( + level: number, + includeLevel: boolean, + ): Log[] { + return this.logs.filter( + (v) => includeLevel ? + v.type.level >= level : + v.type.level > level + ) + } + + getBelow( + level: number, + includeLevel: boolean, + ): Log[] { + return this.logs.filter( + (v) => includeLevel ? + v.type.level <= level : + v.type.level < level + ) + } + + getLogsWithAreas( + areas: string[] + ): Log[] { + return this.logs.filter( + (v) => v.area ? + areas.includes(v.area) : + false + ) + } + + getLogsInArea( + areas: string[] + ): Log[] { + return this.logs.filter( + (v) => { + if (!v.area) { + return false + } + + for (const area of areas) { + if (area.startsWith(v.area)) { + return true + } + } + return false + } + ) + } + + getLogsNotInAreas( + areas: string[] + ): Log[] { + return this.logs.filter( + (v) => v.area ? + !areas.includes(v.area) : + !areas.includes("default") + ) + } + + parseLogs( + logParser: LogParser = defaultLogParser, + ) { + let out: string = "" + for (const log of this.logs) { + out += logParser(log) + "\n" + } + return out + } +} + +export function parseArea( + area: string +): string { + area = area.toLowerCase() + while (area.startsWith("-")) { + area = area.slice(1) + } + + while (area.endsWith("-")) { + area = area.slice(0, -1) + } + + if (area.includes(" ")) { + area = area.split(" ").join("-") + } + if (area.includes("_")) { + area = area.split("_").join("-") + } + if (area.includes("#")) { + area = area.split("#").join("-") + } + if (area.includes(";")) { + area = area.split(";").join("-") + } + if (area.includes("/")) { + area = area.split("/").join("-") + } + if (area.includes("\\")) { + area = area.split("\\").join("-") + } + return area +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9cca968 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ESNext" + ], + "allowJs": false, + "resolveJsonModule": false, + "declaration": true, + "declarationMap": true, + "inlineSourceMap": true, + "inlineSources": true, + "removeComments": false, + "outDir": "dist", + "rootDir": "src", + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true + }, + "include": [ + "src/**/*.ts", + ], + "exclude": [ + "dist", + "node_modules" + ] +} \ No newline at end of file