├── .eslintrc.json ├── .github ├── CODE_OF_CONDUCT.es.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.es.md ├── CONTRIBUTING.md ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature_request.md └── workflows │ ├── codeql-analysis.yml │ └── nodejs.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .npmignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.es.md ├── README.md ├── README.pt.md ├── __tests__ ├── console.service.test.ts ├── controller.test.ts ├── files.service.test.ts ├── files.worker.service.test.ts ├── files.worker.test.ts ├── https.service.test.ts ├── logger.service.test.ts ├── main.test.ts ├── result.service.test.ts ├── spinner.service.test.ts ├── ui │ └── results.ui.test.ts └── update.service.test.ts ├── docs ├── RELEASE.md ├── create-demo.sh ├── npkill proto-logo.svg ├── npkill scopes.svg ├── npkill-alpha-demo.gif ├── npkill-demo-0.10.0.gif ├── npkill-demo-0.3.0.gif ├── npkill-scope-2.svg ├── npkill-scope-mono.svg ├── npkill-text-clean.svg └── npkill-text-outlined.svg ├── gulpfile.mjs ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── constants │ ├── cli.constants.ts │ ├── index.ts │ ├── main.constants.ts │ ├── messages.constants.ts │ ├── recursive-rmdir-node-support.constants.ts │ ├── sort.result.ts │ ├── spinner.constants.ts │ ├── status.constants.ts │ ├── update.constants.ts │ └── workers.constants.ts ├── controller.ts ├── dirname.ts ├── index.ts ├── interfaces │ ├── cli-options.interface.ts │ ├── command-keys.interface.ts │ ├── config.interface.ts │ ├── error-callback.interface.ts │ ├── file-service.interface.ts │ ├── folder.interface.ts │ ├── index.ts │ ├── key-press.interface.ts │ ├── list-dir-params.interface.ts │ ├── node-version.interface.ts │ ├── stats.interface.ts │ ├── ui-positions.interface.ts │ └── version.interface.ts ├── libs │ └── buffer-until.ts ├── main.ts ├── models │ ├── search-state.model.ts │ └── start-parameters.model.ts ├── services │ ├── console.service.ts │ ├── files │ │ ├── files.service.ts │ │ ├── files.worker.service.ts │ │ ├── files.worker.ts │ │ ├── index.ts │ │ ├── linux-files.service.ts │ │ ├── mac-files.service.ts │ │ ├── unix-files.service.ts │ │ └── windows-files.service.ts │ ├── https.service.ts │ ├── index.ts │ ├── logger.service.ts │ ├── results.service.ts │ ├── spinner.service.ts │ ├── stream.service.ts │ ├── ui.service.ts │ └── update.service.ts ├── strategies │ ├── index.ts │ ├── windows-default.strategy.ts │ ├── windows-node12.strategy.ts │ ├── windows-node14.strategy.ts │ ├── windows-remove-dir.strategy.ts │ └── windows-strategy.abstract.ts └── ui │ ├── base.ui.ts │ ├── components │ ├── general.ui.ts │ ├── header │ │ ├── header.ui.ts │ │ ├── stats.ui.ts │ │ └── status.ui.ts │ ├── help.ui.ts │ ├── logs.ui.ts │ ├── results.ui.ts │ └── warning.ui.ts │ ├── heavy.ui.ts │ └── index.ts ├── stryker.conf.js ├── tsconfig.json └── tslint.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": ["standard-with-typescript", "prettier"], 7 | "overrides": [], 8 | "parserOptions": { 9 | "project": "./tsconfig.json", 10 | "ecmaVersion": "latest", 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | "curly": [2, "all"], 15 | "@typescript-eslint/consistent-type-imports": "off", 16 | "@typescript-eslint/dot-notation": "off", 17 | "@typescript-eslint/no-confusing-void-expression": [ 18 | 2, 19 | { 20 | "ignoreArrowShorthand": true 21 | } 22 | ], 23 | "@typescript-eslint/return-await": [2, "in-try-catch"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.es.md: -------------------------------------------------------------------------------- 1 | # Código de Conducta 2 | 3 | ## Nuestro Compromiso 4 | 5 | En el interés de fomentar un entorno abierto y acogedor, nosotros como colaboradores 6 | y mantenedores nos comprometemos a hacer que la participación en nuestro proyecto y 7 | comunidad sea una experiencia libre de acoso para todos, independientemente de edad, 8 | tamaño corporal, discapacidad, etnia, características sexuales, identidad de género 9 | y expresión, nivel de experiencia, educación, estatus socioeconómico, nacionalidad, 10 | apariencia personal, raza, religión o identidad y orientación sexual. 11 | 12 | ## Nuestras Normas 13 | 14 | Ejemplos de comportamiento que contribuyen a crear un entorno positivo son: 15 | 16 | * Utilizar lenguaje inclusivo 17 | * Ser respetuoso con experiencias y puntos de vista distintos al nuestro 18 | * Aceptar críticas contructivas de forma cortés 19 | * Centrarnos en lo que sea mejor para la comunidad 20 | * Mostrar empatía hacia otros miembros de la comunidad 21 | 22 | Ejemplos de comportamiento inaceptable son: 23 | 24 | * Uso de lenguaje sexualizado o imágenes sexuales, así como avances sexuales 25 | indeseados 26 | * Trolling, comentarios insultantes/despectivos, y ataques personales o políticos 27 | * Acoso público o privado 28 | * Publicar la información privada de terceros, como direcciones físicas o electrónicas, 29 | sin permiso explícito. 30 | * Cualquier conducta que, de forma razonable, se considere inapropiada en un ámbito 31 | profesional. 32 | 33 | 34 | ## Nuestras Responsabilidades 35 | 36 | Los mantenedores del proyecto son responsables de aclarar las normas de 37 | comportamiento aceptable y se espera de ellos que tomen medidas apropiadas 38 | en respuesta a cualquier instancia de comportamiento inaceptable. 39 | 40 | Los mantenedores del proyecto tienen el derecho y la responsabilidad de eliminar, 41 | editar o rechazar comentarios, commits, código, ediciones de wiki, issues y 42 | cualquier otra contribución que incumpla este código de conducta, o de banear 43 | temporal o peramenentemente a cualquier colaborador por cualquier comportamiento 44 | que se considere inapropiado, amenazador, ofensivo o dañino. 45 | 46 | 47 | ## Ámbito 48 | 49 | Este Código de Conducta se aplica tanto en el entorno del proyecto como en espacios 50 | públicos donde un individuo representa al proyecto o a su comunidad. Ejemplos de 51 | representar un proyecto o comunidad incluyen utilizar un e-mail oficial del proyecto, 52 | publicaciones hechas vía una cuenta oficial en una red social, o actuar como un 53 | representante oficial en cualquier evento online u offline. La representación de un 54 | proyecto puede ser ampliada o aclarada por los mantenedores del proyecto. 55 | 56 | ## Aplicación 57 | 58 | Instancias de comportamiento abusivo, acosador o inaceptable en cualquier otro sentido 59 | pueden ser comunicadas al equipo del proyecto, a través de las direcciones de correo 60 | electrónico nyablk97@gmail.com o juaniman.2000@gmail.com. Todas las quejas serán 61 | revisadas e investigadas y resultarán en la respuesta que se considere necesaria y 62 | apropiada según las circunstancias. El equipo del proyecto está obligado a mantener 63 | la confidencialidad de cualquier persona que informe de un incidente. 64 | 65 | Los mantenedores del proyecto que no sigan ni apliquen el Código de Conducta pueden 66 | enfrentarse a repercusiones temporales o permanentes, determinadas por otros miembros 67 | del equipo del proyecto. 68 | 69 | ## Atribución 70 | 71 | Este Código de Conducta está adaptado del [Contributor Covenant][homepage], version 1.4, 72 | disponible en https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 73 | 74 | [homepage]: https://www.contributor-covenant.org 75 | 76 | Para ver respuestas a preguntas comunes sobre este código de conducta, véase 77 | https://www.contributor-covenant.org/faq 78 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at nyablk97@gmail.com or juaniman.2000@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.es.md: -------------------------------------------------------------------------------- 1 | **_(Este doc está en proceso de desarrollo)_** 2 | 3 | # Cómo contribuir a NPKILL 🎉 4 | 5 | Sé que lo que voy a decir es lo típico, pero es realmente maravilloso que estés leyendo estas líneas. Quiere decir que estás interesad@ en ayudar a mejorar Npkill, _o quizá simplemente estés aquí por curiosidad `cof cof`_. 6 | 7 | Sea por la razón que sea, eres bienvenid@. A continuación te explico las pautas recomendadas a la hora de contribuir. 8 | 9 | --- 10 | 11 | # Consideraciones habituales 12 | 13 | - Seguir este protocolo ayuda a evitar trabajar en vano. Sería una pena dedicar horas a un pull request y que tengamos que rechazarlo porque ya hay alguien trabajando en un issue similar. 14 | 15 | - A no ser que sean modificaciones menores y rápidas, intenta informar a todo el mundo de que estás modificando algo. Para ello puedes abrir un issue, o consultar los [proyectos](https://github.com/voidcosmos/npkill/projects). 16 | 17 | - Cambia únicamente las líneas que sean necesarias para llevar a cabo la modificación. Esto ayudará a evitar conflictos, y en el caso de que exista alguno, será más fácil de solucionar. 18 | 19 | - Asegúrate de ejecutar `npm install`, ya que algunos paquetes de desarrollo existen para mantener la armonía. Prettier, por ejemplo, se asegura en cada commit de que los ficheros tienen la sangría correctamente, y Commitlint se asegura de que los mensajes de commit siguen la convención. 20 | 21 | - Siempre que sea posible, añade tests, tests y... ¡Más tests! tests tests tests tests tests tests tests tests tests tests tests 22 | 23 | # Nueva feature 24 | 25 | 1. Si quieres contribuir con una nueva feature, asegúrate de que no hay un issue anterior de otra persona trabajando en lo mismo. 26 | 27 | 2. Si no hay, abre un issue explicando lo que quieres incorporar, y los ficheros que, a priori, creas que tendrás que modificar. 28 | 29 | 3. Espera a que la comunidad se pronuncie, y a que algún miembro apruebe tu propuesta (decisión que se tendrá un cuenta por la comunidad). 30 | 31 | ¡Bien! ¡Luz verde para picar! 32 | 33 | 4. Haz un fork de este proyecto. 34 | 35 | 5. Crea una nueva rama siguiendo las convenciones recomendadas. 36 | 37 | 6. Escribe el código y crea commits de forma regular siguiendo la convención recomendada. 38 | 39 | 7. Crea un PULL REQUEST utilizando **master como rama base**. 40 | Como título, utiliza uno igual o similar al que utilizaste en la creación del issue, y en la descripción, cualquier información que consideres relevante junto al enlace al issue y el mensaje "close". Ejemplo: close #numeroIssue 41 | [más info](https://help.github.com/en/articles/closing-issues-using-keywords) 42 | 43 | # Convenciones 44 | 45 | ## Ramas de git 46 | 47 | Recomendamos utilizar la siguiente nomenclatura siempre que sea posible: 48 | 49 | - feat/sort-results 50 | - fix/lstat-crash 51 | - docs/improve-readme 52 | 53 | ## Mensajes de git 54 | 55 | Asegúrate de pensar bien el mensaje de cada commit. 56 | Todos los commits deben utilizar una convención similar a la de `Angular`. [Aquí tienes todas las reglas](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional#type-enum) 57 | 58 | - Utiliza el presente ("add feature", no "added feature") 59 | - Utiliza el imperativo ("move cursor to", no "moves cursor to") 60 | - Limita la primera línea a 72 caracteres o menos 61 | - Referencia issues y pull request tanto como quieras tras la primera línea 62 | 63 | 64 | _[Some points extracted from Atom doc](https://github.com/atom/atom/blob/master/CONTRIBUTING.md#git-commit-messages)_ 65 | 66 | ## Código 67 | 68 | Es importante aplicar los principios del código limpio. 69 | 70 | Si utilizas `VS Code`, a continuación tienes algunos add-ons que recomendamos: 71 | - TSLint: Te permite saber si estás incumpliendo algunas de las _reglas de código_ (no utilizar var, utilizar const siempre que sea posible, tipar siempre las variables etc.) 72 | 73 | - CodeMetrics: Calcula la complejidad de los métodos, para asegurar que cada función hace únicamente 1 cosa. (verde es ok, amarillo es meh, rojo es oh god why) 74 | 75 | Si utilizas otro IDE, probablemente haya add-ons parecidos disponibles. -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | **_(this doc is under construction)_** 2 | 3 | # How to contribute on NPKILL 🎉 4 | 5 | I know that what I am going to say sounds like something typical, but I am sincerely glad that you are reading this, because that means that you are interested in helping to improve Npkill, _or you may simply be here out of curiosity `cof cof`_. 6 | Anyway, you are sincerely welcome. I will try to explain the recommended guidelines to contribute. 7 | 8 | --- 9 | 10 | # Common considerations 11 | 12 | - Following this protocol helps to avoid working in vain. It would be a shame to dedicate hours to a pull request and have to reject it because there is already someone working on a similar issue. 13 | 14 | -Unless they are minor and fast modifications, try to let everyone know that you are modifying something by opening an issue for example, or consulting the [projects](https://github.com/voidcosmos/npkill/projects) 15 | 16 | - Change only the necessary lines for your modification. This will help to avoid conflicts, and in case of there being any, it will be easier to solve them. 17 | 18 | - Make sure you to run `npm install`, because some development packages are meant to maintain harmony. Prettier, for example, makes sure that in each commit the files are well indented, and Commitlint makes sure that your messages follow the convention. 19 | 20 | - Whenever possible, write tests, tests and more tests! tests tests tests tests tests tests tests tests tests tests tests 21 | 22 | # New feature 23 | 24 | 1. If you want to contribute to a new feature, make sure that there isn't a previous issue of someone working on the same feature. 25 | 26 | 2. Then, open an issue explaining what you want to incorporate, and the files that you think you will need to modify a priori. 27 | 28 | 3. Wait for the community to give an opinion, and for some member to approve your proposal (a decision that will be taken into the community and future plans). 29 | 30 | Yay! Green light to work! 31 | 32 | 4. Fork this project. 33 | 34 | 5. Create a new branch following the [recommended conventions]() 35 | 36 | 6. Write code and create commits regularly following the [recommended convention]() 37 | 38 | 7. Create a PULL REQUEST using **master as the base branch**. 39 | As a title, use the same (or similar) one you used in the creation of the issue, and in the description, any information that you consider relevant next to the link of the issue and "close" text (example: close #issueNumber) [more info](https://help.github.com/en/articles/closing-issues-using-keywords) 40 | 41 | # Conventions 42 | 43 | ## git branch 44 | 45 | I recommend using the following nomenclature whenever possible: 46 | 47 | - feat/sort-results 48 | - fix/lstat-crash 49 | - docs/improve-readme 50 | 51 | ## git messages 52 | 53 | Be sure to take your time thinking about the message for each commit. 54 | All commits must use a convention similar to `Angular`. [Here all the rules](https://github.com/conventional-changelog/commitlint/tree/master/%40commitlint/config-conventional#type-enum) 55 | 56 | - Use the present tense ("add feature" not "added feature") 57 | - Use the imperative mood ("move cursor to..." not "moves cursor to...") 58 | - Limit the first line to 72 characters or less 59 | - Reference issues and pull requests liberally after the first line 60 | 61 | _[Some points extracted from Atom doc](https://github.com/atom/atom/blob/master/CONTRIBUTING.md#git-commit-messages)_ 62 | 63 | ## code 64 | 65 | It is important to apply the principles of clean code. 66 | 67 | If you use `VS Code`, there are some add-ons that I recommend: 68 | -TSLint: Lets you know if you are breaking any of the _coding rules_ (do not use var, use const if possible, if some type has not been defined etc) 69 | 70 | - CodeMetrics: Calculates the complexity of the methods, to ensure that your functions do only 1 thing. (green is ok, yellow is meh, red is oh god why) 71 | 72 | If you use a different IDE, there are probably similar add-ons available. 73 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | open_collective: npkill 4 | custom: ['ethereum/0x7668e86c8bdb52034606db5aa0d2d4d73a0d4259'] 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** 10 | A clear and concise description of what the bug is. 11 | 12 | **To Reproduce** 13 | Steps to reproduce the behavior: 14 | 15 | 1. Go to '...' 16 | 2. Command '....' 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | - OS: [e.g. Window] 26 | - Version [ npkill -v ] 27 | 28 | **Additional context** 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | schedule: 16 | - cron: '25 8 * * 1' 17 | workflow_dispatch: 18 | 19 | jobs: 20 | analyze: 21 | name: Analyze 22 | runs-on: ubuntu-latest 23 | permissions: 24 | actions: read 25 | contents: read 26 | security-events: write 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: ['typescript'] 32 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 33 | 34 | steps: 35 | - name: Checkout repository 36 | uses: actions/checkout@v2 37 | 38 | # Initializes the CodeQL tools for scanning. 39 | - name: Initialize CodeQL 40 | uses: github/codeql-action/init@v2 41 | with: 42 | languages: ${{ matrix.language }} 43 | # If you wish to specify custom queries, you can do so here or in a config file. 44 | # By default, queries listed here will override any specified in a config file. 45 | # Prefix the list here with "+" to use these queries and those in the config file. 46 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 47 | 48 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 49 | # If this step fails, then you should remove it and run the build manually (see below) 50 | - name: Autobuild 51 | uses: github/codeql-action/autobuild@v2 52 | 53 | # ℹ️ Command-line programs to run using the OS shell. 54 | # 📚 https://git.io/JvXDl 55 | 56 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 57 | # and modify them (or add more) to build your code if your project 58 | # uses a compiled language 59 | 60 | #- run: | 61 | # make bootstrap 62 | # make release 63 | 64 | - name: Perform CodeQL Analysis 65 | uses: github/codeql-action/analyze@v2 66 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: [push, pull_request, pull_request_review] 7 | 8 | jobs: 9 | testing: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest] 15 | node-version: [14, 16, 18, 22] 16 | include: 17 | - os: macos-latest 18 | node-version: 21 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: 'npm' 27 | 28 | - name: Install Dependencies 29 | run: npm ci --ignore-scripts 30 | 31 | - run: npm test 32 | env: 33 | CI: true 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | out.txt 4 | # stryker temp files 5 | .stryker-tmp 6 | stryker.log 7 | reports 8 | coverage 9 | stuff 10 | test-files 11 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tslint.json 4 | .prettierrc -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/lib/index.js" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "debug.javascript.autoAttachFilter": "onlyWithFlag" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Estefanía García Gallardo and Juan Torres Gómez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /__tests__/console.service.test.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleService } from '../src/services/console.service.js'; 2 | 3 | describe('Console Service', () => { 4 | let consoleService: ConsoleService; 5 | beforeAll(() => { 6 | consoleService = new ConsoleService(); 7 | }); 8 | 9 | describe('#getParameters', () => { 10 | it('should get valid parameters', () => { 11 | const argvs = [ 12 | '/usr/bin/ts-node', 13 | '/blablabla inexistent parameters', 14 | '-h', 15 | '--directory', 16 | '/sample/path', 17 | '-D', 18 | 'lala', 19 | 'random text', 20 | '-f', 21 | '--exclude-hidden-directories', 22 | ]; 23 | 24 | const result = consoleService.getParameters(argvs); 25 | 26 | expect(result.isTrue('help')).not.toBeFalsy(); 27 | expect(result.getString('directory')).toBe('/sample/path'); 28 | expect(result.isTrue('delete-all')).not.toBeFalsy(); 29 | expect(result.isTrue('lala')).toBeFalsy(); 30 | expect(result.isTrue('inexistent')).toBeFalsy(); 31 | expect(result.isTrue('full-scan')).not.toBeFalsy(); 32 | expect(result.isTrue('exclude-hidden-directories')).not.toBeFalsy(); 33 | }); 34 | it('should get valid parameters 2', () => { 35 | const argvs = [ 36 | '/usr/bin/ts-node', 37 | '/blablabla inexistent parameters', 38 | '-f', 39 | 'lala', 40 | '--sort=size', 41 | '-c', 42 | 'red', 43 | ]; 44 | 45 | const result = consoleService.getParameters(argvs); 46 | expect(result.isTrue('help')).toBeFalsy(); 47 | expect(result.isTrue('full-scan')).not.toBeFalsy(); 48 | expect(result.getString('bg-color')).toBe('red'); 49 | expect(result.getString('sort-by')).toBe('size'); 50 | expect(result.isTrue('exclude-hidden-directories')).toBeFalsy(); 51 | }); 52 | }); 53 | 54 | describe('#splitData', () => { 55 | it('should split data with default separator', () => { 56 | expect(consoleService.splitData('foo\nbar\nfoot')).toEqual([ 57 | 'foo', 58 | 'bar', 59 | 'foot', 60 | ]); 61 | }); 62 | it('should split data with custom separator', () => { 63 | expect(consoleService.splitData('foo;bar;foot', ';')).toEqual([ 64 | 'foo', 65 | 'bar', 66 | 'foot', 67 | ]); 68 | }); 69 | it('should return empty array if data is empty', () => { 70 | expect(consoleService.splitData('')).toEqual([]); 71 | }); 72 | }); 73 | 74 | describe('#splitWordsByWidth', () => { 75 | it('should get array with text according to width', () => { 76 | const cases = [ 77 | { 78 | expect: [ 79 | 'Lorem ipsum dolor sit amet, consectetur', 80 | 'adipiscing elit. Mauris faucibus sit amet', 81 | 'libero non vestibulum. Morbi ac tellus', 82 | 'dolor. Duis consectetur eget lectus sed', 83 | 'ullamcorper.', 84 | ], 85 | text: 86 | // tslint:disable-next-line: max-line-length 87 | 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Mauris faucibus sit amet libero non vestibulum. Morbi ac tellus dolor. Duis consectetur eget lectus sed ullamcorper.', 88 | width: 43, 89 | }, 90 | /* { 91 | text: 'Lorem ipsum dolor sit amet.', 92 | width: 2, 93 | expect: ['Lorem', 'ipsum', 'dolor', 'sit', 'amet.'], 94 | }, */ 95 | ]; 96 | 97 | cases.forEach((cas) => { 98 | expect(consoleService.splitWordsByWidth(cas.text, cas.width)).toEqual( 99 | cas.expect, 100 | ); 101 | }); 102 | }); 103 | }); 104 | 105 | describe('#shortenText', () => { 106 | it('should short text according parameters', () => { 107 | const cases = [ 108 | { 109 | cutFrom: 0, 110 | expect: '...', 111 | text: '/sample/text/for/test how/service/split/thisA', 112 | width: 0, 113 | }, 114 | { 115 | cutFrom: 10, 116 | expect: '/sample/te.../service/split/this', 117 | text: '/sample/text/for/test how/service/split/this', 118 | width: 32, 119 | }, 120 | { 121 | cutFrom: 5, 122 | expect: '/aaa/.../jjj/kkk', 123 | text: '/aaa/bbb/ccc/ddd/eee/fff/ggg/hhhh/iiii/jjj/kkk', 124 | width: 16, 125 | }, 126 | { 127 | cutFrom: 3, 128 | expect: '/neketaro/a:desktop/folder', 129 | text: '/neketaro/a:desktop/folder', 130 | width: 50, 131 | }, 132 | ]; 133 | 134 | cases.forEach((cas) => { 135 | const result = consoleService.shortenText( 136 | cas.text, 137 | cas.width, 138 | cas.cutFrom, 139 | ); 140 | expect(result).toEqual(cas.expect); 141 | }); 142 | }); 143 | 144 | it('should no modify input if "cutFrom" > text length', () => { 145 | const text = '/sample/text/'; 146 | const expectResult = '/sample/text/'; 147 | const width = 5; 148 | const cutFrom = 50; 149 | 150 | const result = consoleService.shortenText(text, width, cutFrom); 151 | expect(result).toEqual(expectResult); 152 | }); 153 | 154 | it('should no modify input if "cutFrom" > width', () => { 155 | const text = '/sample/text/'; 156 | const expectResult = '/sample/text/'; 157 | const width = 5; 158 | const cutFrom = 7; 159 | 160 | const result = consoleService.shortenText(text, width, cutFrom); 161 | expect(result).toEqual(expectResult); 162 | }); 163 | 164 | it('should ignore negative parameters', () => { 165 | const cases = [ 166 | { 167 | cutFrom: -10, 168 | expect: '/sample/text/for/test how/service/split/thisA', 169 | text: '/sample/text/for/test how/service/split/thisA', 170 | width: 5, 171 | }, 172 | { 173 | cutFrom: 10, 174 | expect: '/sample/text/for/test how/service/split/thisB', 175 | text: '/sample/text/for/test how/service/split/thisB', 176 | width: -10, 177 | }, 178 | { 179 | cutFrom: -20, 180 | expect: '/sample/text/for/test how/service/split/thisC', 181 | text: '/sample/text/for/test how/service/split/thisC', 182 | width: -10, 183 | }, 184 | ]; 185 | 186 | cases.forEach((cas) => { 187 | const result = consoleService.shortenText( 188 | cas.text, 189 | cas.width, 190 | cas.cutFrom, 191 | ); 192 | expect(result).toEqual(cas.expect); 193 | }); 194 | }); 195 | }); 196 | }); 197 | -------------------------------------------------------------------------------- /__tests__/files.service.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import fs from 'fs'; 3 | 4 | import { IFileService } from '../src/interfaces/file-service.interface.js'; 5 | import * as rimraf from 'rimraf'; 6 | 7 | let statSyncReturnMock = (): { isDirectory: () => boolean } | null => null; 8 | let accessSyncReturnMock = (): boolean | null => null; 9 | const readFileSyncSpy = jest.fn(); 10 | jest.unstable_mockModule('fs', () => { 11 | return { 12 | statSync: (path) => statSyncReturnMock(), 13 | accessSync: (path, flag) => accessSyncReturnMock(), 14 | readFileSync: readFileSyncSpy, 15 | lstat: jest.fn(), 16 | readdir: jest.fn(), 17 | rmdir: jest.fn(), 18 | unlink: jest.fn(), 19 | rm: jest.fn(), 20 | default: { constants: { R_OK: 4 } }, 21 | }; 22 | }); 23 | 24 | jest.useFakeTimers(); 25 | 26 | const FileServiceConstructor = //@ts-ignore 27 | (await import('../src/services/files/files.service.js')).FileService; 28 | abstract class FileService extends FileServiceConstructor {} 29 | 30 | const LinuxFilesServiceConstructor = //@ts-ignore 31 | (await import('../src/services/files/linux-files.service.js')) 32 | .LinuxFilesService; 33 | class LinuxFilesService extends LinuxFilesServiceConstructor {} 34 | 35 | const MacFilesServiceConstructor = //@ts-ignore 36 | (await import('../src/services/files/mac-files.service.js')).MacFilesService; 37 | class MacFilesService extends MacFilesServiceConstructor {} 38 | 39 | const WindowsFilesServiceConstructor = //@ts-ignore 40 | (await import('../src/services/files/windows-files.service.js')) 41 | .WindowsFilesService; 42 | class WindowsFilesService extends WindowsFilesServiceConstructor {} 43 | 44 | import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'fs'; 45 | import { StreamService } from '../src/services/stream.service.js'; 46 | 47 | jest.mock('../src/dirname.js', () => { 48 | return { __esModule: true }; 49 | }); 50 | 51 | const fileWorkerService: any = jest.fn(); 52 | 53 | describe('File Service', () => { 54 | let fileService: FileService; 55 | 56 | beforeEach(() => { 57 | fileService = new LinuxFilesService(new StreamService(), fileWorkerService); 58 | }); 59 | 60 | describe('isValidRootFolder', () => { 61 | const path = '/sample/path'; 62 | 63 | afterEach(() => { 64 | jest.restoreAllMocks(); 65 | statSyncReturnMock = () => null; 66 | statSyncReturnMock = () => null; 67 | }); 68 | 69 | it('should throw error if statSync fail', () => { 70 | statSyncReturnMock = () => { 71 | throw new Error('ENOENT'); 72 | }; 73 | expect(() => fileService.isValidRootFolder(path)).toThrowError( 74 | 'The path does not exist.', 75 | ); 76 | }); 77 | 78 | it('should throw error if is not directory', () => { 79 | statSyncReturnMock = () => ({ 80 | isDirectory: () => false, 81 | }); 82 | 83 | expect(() => fileService.isValidRootFolder(path)).toThrowError( 84 | 'The path must point to a directory.', 85 | ); 86 | }); 87 | 88 | it('should throw error if cant read dir', () => { 89 | statSyncReturnMock = () => ({ 90 | isDirectory: () => true, 91 | }); 92 | accessSyncReturnMock = () => { 93 | throw new Error(); 94 | }; 95 | 96 | expect(() => fileService.isValidRootFolder(path)).toThrowError( 97 | 'Cannot read the specified path.', 98 | ); 99 | }); 100 | 101 | it('should return true if is valid rootfolder', () => { 102 | statSyncReturnMock = () => ({ 103 | isDirectory: () => true, 104 | }); 105 | accessSyncReturnMock = () => true; 106 | 107 | expect(fileService.isValidRootFolder(path)).toBeTruthy(); 108 | }); 109 | }); 110 | 111 | describe('Conversion methods', () => { 112 | it('#convertKbToGB', () => { 113 | expect(fileService.convertKbToGB(100000)).toBe(0.095367431640625); 114 | expect(fileService.convertKbToGB(140000)).toBe(0.133514404296875); 115 | }); 116 | it('#convertBytesToKB', () => { 117 | expect(fileService.convertBytesToKB(1)).toBe(0.0009765625); 118 | expect(fileService.convertBytesToKB(100)).toBe(0.09765625); 119 | expect(fileService.convertBytesToKB(96)).toBe(0.09375); 120 | }); 121 | it('#convertGBToMB', () => { 122 | expect(fileService.convertGBToMB(1)).toBe(1024); 123 | expect(fileService.convertGBToMB(100)).toBe(102400); 124 | expect(fileService.convertGBToMB(96)).toBe(98304); 125 | }); 126 | }); 127 | 128 | describe('#isSafeToDelete', () => { 129 | const target = 'node_modules'; 130 | 131 | it('should get false if not is safe to delete ', () => { 132 | expect(fileService.isSafeToDelete('/one/route', target)).toBeFalsy(); 133 | expect( 134 | fileService.isSafeToDelete('/one/node_/ro/modules', target), 135 | ).toBeFalsy(); 136 | expect(fileService.isSafeToDelete('nodemodules', target)).toBeFalsy(); 137 | }); 138 | 139 | it('should get true if is safe to delete ', () => { 140 | expect( 141 | fileService.isSafeToDelete('/one/route/node_modules', target), 142 | ).toBeTruthy(); 143 | expect( 144 | fileService.isSafeToDelete('/one/route/node_modules/', target), 145 | ).toBeTruthy(); 146 | }); 147 | }); 148 | 149 | describe('#isDangerous', () => { 150 | it('should return false for paths that are not considered dangerous', () => { 151 | expect( 152 | fileService.isDangerous('/home/apps/myapp/node_modules'), 153 | ).toBeFalsy(); 154 | expect(fileService.isDangerous('node_modules')).toBeFalsy(); 155 | expect( 156 | fileService.isDangerous('/home/user/projects/a/node_modules'), 157 | ).toBeFalsy(); 158 | expect( 159 | fileService.isDangerous('/Applications/NotAnApp/node_modules'), 160 | ).toBeFalsy(); 161 | expect( 162 | fileService.isDangerous('C:\\Users\\User\\Documents\\node_modules'), 163 | ).toBeFalsy(); 164 | }); 165 | 166 | it('should return true for paths that are considered dangerous', () => { 167 | expect( 168 | fileService.isDangerous('/home/.config/myapp/node_modules'), 169 | ).toBeTruthy(); 170 | expect(fileService.isDangerous('.apps/node_modules')).toBeTruthy(); 171 | expect( 172 | fileService.isDangerous('.apps/.sample/node_modules'), 173 | ).toBeTruthy(); 174 | expect( 175 | fileService.isDangerous('/Applications/MyApp.app/node_modules'), 176 | ).toBeTruthy(); 177 | expect( 178 | fileService.isDangerous( 179 | 'C:\\Users\\User\\AppData\\Local\\node_modules', 180 | ), 181 | ).toBeTruthy(); 182 | }); 183 | }); 184 | 185 | it('#getFileContent should read file content with utf8 encoding', () => { 186 | const path = 'file.json'; 187 | fileService.getFileContent(path); 188 | expect(readFileSyncSpy).toBeCalledWith(path, 'utf8'); 189 | }); 190 | 191 | xdescribe('Functional test for #deleteDir', () => { 192 | let fileService: IFileService; 193 | const testFolder = 'test-files'; 194 | const directories = [ 195 | 'testProject', 196 | 'awesome-fake-project', 197 | 'a', 198 | 'ewez', 199 | 'potato and bananas', 200 | ]; 201 | 202 | const createDir = (dir) => mkdirSync(dir); 203 | const isDirEmpty = (dir) => readdirSync(dir).length === 0; 204 | const createFileWithSize = (filename, mb) => 205 | writeFileSync(filename, Buffer.alloc(1024 * 1024 * mb)); 206 | 207 | beforeAll(() => { 208 | const getOS = () => process.platform; 209 | const OSService = { 210 | linux: LinuxFilesService, 211 | win32: WindowsFilesService, 212 | darwin: MacFilesService, 213 | }; 214 | const streamService: StreamService = new StreamService(); 215 | fileService = new OSService[getOS()](streamService); 216 | 217 | if (existsSync(testFolder)) { 218 | rimraf.sync(testFolder); 219 | } 220 | createDir(testFolder); 221 | 222 | directories.forEach((dirName) => { 223 | const basePath = `${testFolder}/${dirName}`; 224 | const targetFolder = `${basePath}/node_modules`; 225 | const subfolder = `${targetFolder}/sample subfolder`; 226 | createDir(basePath); 227 | createDir(targetFolder); 228 | createDir(subfolder); 229 | createFileWithSize(targetFolder + '/a', 30); 230 | createFileWithSize(subfolder + '/sample file', 12); 231 | // Create this structure: 232 | // test-files 233 | // |testProject 234 | // |a (file) 235 | // |sample subfolder 236 | // |sample file (file) 237 | // |etc... 238 | }); 239 | }); 240 | 241 | afterAll(() => { 242 | rimraf.sync(testFolder); 243 | }); 244 | 245 | it('Test folder should not be empty', () => { 246 | expect(isDirEmpty(testFolder)).toBeFalsy(); 247 | }); 248 | 249 | it('Should delete all folders created in test folder', async () => { 250 | for (const dirName of directories) { 251 | const path = `${testFolder}/${dirName}`; 252 | expect(existsSync(path)).toBeTruthy(); 253 | await fileService.deleteDir(path); 254 | expect(existsSync(path)).toBeFalsy(); 255 | } 256 | expect(isDirEmpty(testFolder)).toBeTruthy(); 257 | }); 258 | }); 259 | 260 | describe('fakeDeleteDir', () => { 261 | it('Should return a Promise', () => { 262 | const result = fileService.fakeDeleteDir('/sample/path'); 263 | expect(result).toBeInstanceOf(Promise); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /__tests__/files.worker.service.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import EventEmitter from 'node:events'; 3 | 4 | import { Subject } from 'rxjs'; 5 | import { EVENTS } from '../src/constants/workers.constants'; 6 | import { IListDirParams } from '../src/interfaces'; 7 | import { SearchStatus } from '../src/models/search-state.model'; 8 | import { WorkerMessage } from '../src/services/files/files.worker.service'; 9 | import { LoggerService } from '../src/services/logger.service'; 10 | 11 | const workerEmitter: EventEmitter = new EventEmitter(); 12 | const port1Emitter: EventEmitter = new EventEmitter(); 13 | const port2Emitter: EventEmitter = new EventEmitter(); 14 | const workerPostMessageMock = jest.fn(); 15 | const workerTerminateMock = jest 16 | .fn() 17 | .mockImplementation(() => new Promise(() => {})); 18 | const messageChannelPort1Mock = jest.fn(); 19 | const messageChannelPort2Mock = jest.fn(); 20 | 21 | jest.unstable_mockModule('os', () => ({ 22 | default: { cpus: jest.fn().mockReturnValue([0, 0]) }, 23 | })); 24 | 25 | jest.unstable_mockModule('node:worker_threads', () => ({ 26 | Worker: jest.fn(() => ({ 27 | postMessage: workerPostMessageMock, 28 | on: (eventName: string, listener: (...args: any[]) => void) => 29 | workerEmitter.on(eventName, listener), 30 | terminate: workerTerminateMock, 31 | removeAllListeners: jest.fn(), 32 | })), 33 | 34 | MessageChannel: jest.fn(() => ({ 35 | port1: { 36 | postMessage: messageChannelPort1Mock, 37 | on: (eventName: string, listener: (...args: any[]) => void) => 38 | port1Emitter.on(eventName, listener), 39 | removeAllListeners: jest.fn(), 40 | }, 41 | port2: { 42 | postMessage: messageChannelPort2Mock, 43 | on: (eventName: string, listener: (...args: any[]) => void) => 44 | port2Emitter.on(eventName, listener), 45 | removeAllListeners: jest.fn(), 46 | }, 47 | })), 48 | })); 49 | 50 | const logger = { 51 | info: jest.fn(), 52 | } as unknown as jest.Mocked; 53 | 54 | const FileWorkerServiceConstructor = //@ts-ignore 55 | (await import('../src/services/files/files.worker.service.js')) 56 | .FileWorkerService; 57 | class FileWorkerService extends FileWorkerServiceConstructor {} 58 | 59 | describe('FileWorkerService', () => { 60 | let fileWorkerService: FileWorkerService; 61 | let searchStatus: SearchStatus; 62 | let params: IListDirParams; 63 | 64 | beforeEach(async () => { 65 | const aa = new URL('http://127.0.0.1'); // Any valid URL. Is not used 66 | jest.spyOn(global, 'URL').mockReturnValue(aa); 67 | 68 | searchStatus = new SearchStatus(); 69 | fileWorkerService = new FileWorkerService(logger, searchStatus); 70 | params = { 71 | path: '/path/to/directory', 72 | target: 'node_modules', 73 | }; 74 | }); 75 | 76 | afterEach(() => { 77 | jest.restoreAllMocks(); 78 | workerEmitter.removeAllListeners(); 79 | port1Emitter.removeAllListeners(); 80 | port2Emitter.removeAllListeners(); 81 | }); 82 | 83 | describe('startScan', () => { 84 | let stream$: Subject; 85 | 86 | beforeEach(() => { 87 | stream$ = new Subject(); 88 | }); 89 | 90 | afterEach(() => { 91 | jest.restoreAllMocks(); 92 | }); 93 | 94 | it('should emit "explore" and parameters to the worker', () => { 95 | fileWorkerService.startScan(stream$, params); 96 | expect(messageChannelPort1Mock).toBeCalledWith({ 97 | type: EVENTS.explore, 98 | value: { path: params.path }, 99 | }); 100 | }); 101 | 102 | it('should emit result to the streams on "scanResult"', (done) => { 103 | fileWorkerService.startScan(stream$, params); 104 | const val1 = ['/sample/path1/node_modules']; 105 | const val2 = ['/sample/path2/node_modules', '/sample/path3/otherDir']; 106 | 107 | const result: string[] = []; 108 | stream$.subscribe((data) => { 109 | result.push(data); 110 | if (result.length === 3) { 111 | expect(result[0]).toBe(val1[0]); 112 | expect(result[1]).toBe(val2[0]); 113 | expect(result[2]).toBe(val2[1]); 114 | done(); 115 | } 116 | }); 117 | 118 | port1Emitter.emit('message', { 119 | type: EVENTS.scanResult, 120 | value: { 121 | workerId: 1, 122 | results: [{ path: val1[0], isTarget: true }], 123 | pending: 0, 124 | }, 125 | } as WorkerMessage); 126 | port1Emitter.emit('message', { 127 | type: EVENTS.scanResult, 128 | value: { 129 | workerId: 2, 130 | results: [ 131 | { path: val2[0], isTarget: true }, 132 | { path: val2[1], isTarget: true }, 133 | ], 134 | pending: 342, 135 | }, 136 | }); 137 | }); 138 | 139 | it('should add a job on "scanResult" when folder is not a target', () => { 140 | fileWorkerService.startScan(stream$, params); 141 | const val = [ 142 | '/path/1/valid', 143 | '/path/im/target', 144 | '/path/other/target', 145 | '/path/2/valid', 146 | ]; 147 | 148 | port1Emitter.emit('message', { 149 | type: EVENTS.scanResult, 150 | value: { 151 | workerId: 1, 152 | results: [ 153 | { path: val[0], isTarget: false }, 154 | { path: val[1], isTarget: true }, 155 | { path: val[2], isTarget: true }, 156 | { path: val[3], isTarget: false }, 157 | ], 158 | pending: 0, 159 | }, 160 | } as WorkerMessage); 161 | 162 | expect(messageChannelPort1Mock).toBeCalledWith({ 163 | type: EVENTS.explore, 164 | value: { path: val[0] }, 165 | }); 166 | 167 | expect(messageChannelPort1Mock).toHaveBeenCalledWith({ 168 | type: EVENTS.explore, 169 | value: { path: val[3] }, 170 | }); 171 | 172 | expect(messageChannelPort1Mock).not.toHaveBeenCalledWith({ 173 | type: EVENTS.explore, 174 | value: { path: val[2] }, 175 | }); 176 | }); 177 | 178 | it('should update searchStatus workerStatus on "alive"', () => { 179 | fileWorkerService.startScan(stream$, params); 180 | port1Emitter.emit('message', { 181 | type: 'alive', 182 | value: null, 183 | }); 184 | 185 | expect(searchStatus.workerStatus).toBe('scanning'); 186 | }); 187 | 188 | it('should complete the stream and change worker status when all works have 0 pending tasks', (done) => { 189 | fileWorkerService.startScan(stream$, params); 190 | stream$.subscribe({ 191 | complete: () => { 192 | done(); 193 | }, 194 | }); 195 | 196 | port1Emitter.emit('message', { 197 | type: EVENTS.scanResult, 198 | value: { 199 | workerId: 0, 200 | results: [], 201 | pending: 0, 202 | }, 203 | }); 204 | 205 | expect(searchStatus.workerStatus).toBe('finished'); 206 | }); 207 | 208 | it('should throw error on "error"', () => { 209 | expect(() => { 210 | fileWorkerService.startScan(stream$, params); 211 | workerEmitter.emit('error'); 212 | expect(searchStatus.workerStatus).toBe('dead'); 213 | }).toThrowError(); 214 | }); 215 | 216 | it('should register worker exit on "exit"', () => { 217 | fileWorkerService.startScan(stream$, params); 218 | 219 | logger.info.mockReset(); 220 | workerEmitter.emit('exit'); 221 | expect(logger.info).toBeCalledTimes(1); 222 | }); 223 | }); 224 | }); 225 | 226 | // describe('getSize', () => { 227 | // let stream$: Subject; 228 | // const path = '/sample/file/path'; 229 | 230 | // const mockRandom = (value: number) => 231 | // jest.spyOn(global.Math, 'random').mockReturnValue(value); 232 | 233 | // beforeEach(() => { 234 | // stream$ = new Subject(); 235 | // workerPostMessageMock.mockClear(); 236 | // }); 237 | 238 | // it('should emit "start-explore" and parameters to the worker', () => { 239 | // const randomNumber = 0.12341234; 240 | // mockRandom(randomNumber); 241 | 242 | // fileWorkerService.getSize(stream$, path); 243 | // expect(workerPostMessageMock).toBeCalledWith({ 244 | // type: 'start-getSize', 245 | // value: { path: path, id: randomNumber }, 246 | // }); 247 | // }); 248 | 249 | // it('should received "job completed" with same id, emit to the stream and complete it', (done) => { 250 | // const randomNumber = 0.8832342; 251 | // const response = 42342; 252 | // mockRandom(randomNumber); 253 | 254 | // fileWorkerService.getSize(stream$, path); 255 | 256 | // let streamValues = []; 257 | // stream$.subscribe({ 258 | // next: (data) => { 259 | // streamValues.push(data); 260 | // }, 261 | // complete: () => { 262 | // expect(streamValues.length).toBe(1); 263 | // expect(streamValues[0]).toBe(response); 264 | // done(); 265 | // }, 266 | // }); 267 | 268 | // eventEmitter.emit('message', { 269 | // type: `getsize-job-completed-${randomNumber}`, 270 | // value: response, 271 | // }); 272 | // }); 273 | // }); 274 | -------------------------------------------------------------------------------- /__tests__/files.worker.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import EventEmitter from 'node:events'; 3 | import { Dir } from 'node:fs'; 4 | import { join, normalize } from 'node:path'; 5 | import { MessageChannel, MessagePort } from 'node:worker_threads'; 6 | 7 | import { EVENTS } from '../src/constants/workers.constants'; 8 | import { IListDirParams } from '../src/interfaces'; 9 | 10 | const parentEmitter: EventEmitter = new EventEmitter(); 11 | let tunnelEmitter: MessagePort; 12 | const tunnelPostMock = jest.fn(); 13 | 14 | let dirEntriesMock: { name: string; isDirectory: () => void }[] = []; 15 | const basePath = '/home/user/'; 16 | const target = 'node_modules'; 17 | 18 | // const opendirPathMock = jest.fn(); 19 | // const opendirDirMock = jest.fn(); 20 | // class MockDir extends EventEmitter { 21 | // private entries: Dirent[]; 22 | 23 | // constructor(entries: Dirent[]) { 24 | // super(); 25 | // this.entries = entries; 26 | // } 27 | 28 | // read(): Promise { 29 | // return new Promise((resolve, reject) => { 30 | // if (this.entries.length === 0) { 31 | // this.emit('close'); 32 | // resolve(null); 33 | // } else { 34 | // resolve(this.entries.shift()); 35 | // } 36 | // }); 37 | // } 38 | // } 39 | 40 | const mockDir = { 41 | read: () => { 42 | if (dirEntriesMock.length > 0) { 43 | return Promise.resolve(dirEntriesMock.shift()); 44 | } else { 45 | return Promise.resolve(null); 46 | } 47 | }, 48 | close: () => {}, 49 | } as unknown as Dir; 50 | 51 | jest.unstable_mockModule('fs/promises', () => ({ 52 | opendir: (path: string) => new Promise((resolve) => resolve(mockDir)), 53 | })); 54 | 55 | jest.unstable_mockModule('node:worker_threads', () => ({ 56 | parentPort: { 57 | postMessage: tunnelPostMock, 58 | on: (eventName: string, listener: (...args: any[]) => void) => 59 | parentEmitter.on(eventName, listener), 60 | }, 61 | })); 62 | 63 | describe('FileWorker', () => { 64 | const setExploreConfig = (params: IListDirParams) => { 65 | tunnelEmitter.postMessage({ 66 | type: EVENTS.exploreConfig, 67 | value: params, 68 | }); 69 | }; 70 | 71 | beforeEach(async () => { 72 | await import('../src/services/files/files.worker.js'); 73 | 74 | const { port1, port2 } = new MessageChannel(); 75 | tunnelEmitter = port1; 76 | 77 | parentEmitter.emit('message', { 78 | type: EVENTS.startup, 79 | value: { channel: port2 }, 80 | }); 81 | }); 82 | 83 | afterEach(() => { 84 | jest.resetModules(); 85 | jest.restoreAllMocks(); 86 | parentEmitter.removeAllListeners(); 87 | tunnelEmitter.close(); 88 | }); 89 | 90 | // it('should plant a listener over the passed MessagePort',()=>{}) 91 | 92 | it('should return only sub-directories from given parent', (done) => { 93 | setExploreConfig({ path: basePath, target }); 94 | const subDirectories = [ 95 | { name: 'file1.txt', isDirectory: () => false }, 96 | { name: 'file2.txt', isDirectory: () => false }, 97 | { name: 'dir1', isDirectory: () => true }, 98 | { name: 'file3.txt', isDirectory: () => false }, 99 | { name: 'dir2', isDirectory: () => true }, 100 | ]; 101 | 102 | const expectedResult = subDirectories 103 | .filter((subdir) => subdir.isDirectory()) 104 | .map((subdir) => ({ 105 | path: join(basePath, subdir.name), 106 | isTarget: false, 107 | })); 108 | 109 | dirEntriesMock = [...subDirectories]; 110 | 111 | let results: any[]; 112 | 113 | tunnelEmitter.on('message', (message) => { 114 | if (message.type === EVENTS.scanResult) { 115 | results = message.value.results; 116 | 117 | done(); 118 | expect(results).toEqual(expectedResult); 119 | } 120 | }); 121 | 122 | tunnelEmitter.postMessage({ 123 | type: EVENTS.explore, 124 | value: { path: '/home/user/' }, 125 | }); 126 | }); 127 | 128 | describe('should mark "isTarget" correctly', () => { 129 | const sampleTargets = ['node_modules', 'dist']; 130 | 131 | sampleTargets.forEach((target) => { 132 | it('when target is ' + target, (done) => { 133 | setExploreConfig({ path: basePath, target: 'node_modules' }); 134 | const subDirectories = [ 135 | { name: 'file1.cs', isDirectory: () => false }, 136 | { name: '.gitignore', isDirectory: () => false }, 137 | { name: 'dir1', isDirectory: () => true }, 138 | { name: 'node_modules', isDirectory: () => true }, 139 | { name: 'file3.txt', isDirectory: () => false }, 140 | { name: 'dir2', isDirectory: () => true }, 141 | ]; 142 | dirEntriesMock = [...subDirectories]; 143 | 144 | const expectedResult = subDirectories 145 | .filter((subdir) => subdir.isDirectory()) 146 | .map((subdir) => ({ 147 | path: join(basePath, subdir.name), 148 | isTarget: subdir.name === 'node_modules', 149 | })); 150 | 151 | let results: any[]; 152 | 153 | tunnelEmitter.on('message', (message) => { 154 | if (message.type === EVENTS.scanResult) { 155 | results = message.value.results; 156 | 157 | expect(results).toEqual(expectedResult); 158 | done(); 159 | } 160 | }); 161 | 162 | tunnelEmitter.postMessage({ 163 | type: EVENTS.explore, 164 | value: { path: '/home/user/' }, 165 | }); 166 | }); 167 | }); 168 | }); 169 | 170 | describe('should exclude dir', () => { 171 | it('when a simple patterns is gived', (done) => { 172 | const excluded = ['ignorethis', 'andignorethis']; 173 | setExploreConfig({ 174 | path: basePath, 175 | target: 'node_modules', 176 | exclude: excluded, 177 | }); 178 | const subDirectories = [ 179 | { name: 'file1.cs', isDirectory: () => false }, 180 | { name: '.gitignore', isDirectory: () => false }, 181 | { name: 'dir1', isDirectory: () => true }, 182 | { name: 'node_modules', isDirectory: () => true }, 183 | { name: 'ignorethis', isDirectory: () => true }, 184 | { name: 'andignorethis', isDirectory: () => true }, 185 | { name: 'dir2', isDirectory: () => true }, 186 | ]; 187 | dirEntriesMock = [...subDirectories]; 188 | 189 | const expectedResult = subDirectories 190 | .filter( 191 | (subdir) => subdir.isDirectory() && !excluded.includes(subdir.name), 192 | ) 193 | .map((subdir) => ({ 194 | path: join(basePath, subdir.name), 195 | isTarget: subdir.name === 'node_modules', 196 | })); 197 | 198 | let results: any[]; 199 | tunnelEmitter.on('message', (message) => { 200 | if (message.type === EVENTS.scanResult) { 201 | results = message.value.results; 202 | 203 | done(); 204 | expect(results).toEqual(expectedResult); 205 | } 206 | }); 207 | 208 | tunnelEmitter.postMessage({ 209 | type: EVENTS.explore, 210 | value: { path: '/home/user/' }, 211 | }); 212 | }); 213 | 214 | it('when a part of path is gived', (done) => { 215 | const excluded = ['user/ignorethis']; 216 | setExploreConfig({ 217 | path: basePath, 218 | target: 'node_modules', 219 | exclude: excluded.map(normalize), 220 | }); 221 | const subDirectories = [ 222 | { name: 'file1.cs', isDirectory: () => false }, 223 | { name: '.gitignore', isDirectory: () => false }, 224 | { name: 'dir1', isDirectory: () => true }, 225 | { name: 'node_modules', isDirectory: () => true }, 226 | { name: 'ignorethis', isDirectory: () => true }, 227 | { name: 'andNOTignorethis', isDirectory: () => true }, 228 | { name: 'dir2', isDirectory: () => true }, 229 | ]; 230 | dirEntriesMock = [...subDirectories]; 231 | 232 | const expectedResult = subDirectories 233 | .filter( 234 | (subdir) => subdir.isDirectory() && subdir.name !== 'ignorethis', 235 | ) 236 | .map((subdir) => ({ 237 | path: join(basePath, subdir.name), 238 | isTarget: subdir.name === 'node_modules', 239 | })); 240 | 241 | let results: any[]; 242 | tunnelEmitter.on('message', (message) => { 243 | if (message.type === EVENTS.scanResult) { 244 | results = message.value.results; 245 | 246 | done(); 247 | expect(results).toEqual(expectedResult); 248 | } 249 | }); 250 | 251 | tunnelEmitter.postMessage({ 252 | type: EVENTS.explore, 253 | value: { path: '/home/user/' }, 254 | }); 255 | }); 256 | }); 257 | }); 258 | -------------------------------------------------------------------------------- /__tests__/https.service.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { EventEmitter } from 'events'; 3 | 4 | let statusCodeMock = 200; 5 | const eventEmitter = new EventEmitter(); 6 | const eventEmitter2 = new EventEmitter(); 7 | const response = () => ({ 8 | statusCode: statusCodeMock, 9 | setEncoding: jest.fn(), 10 | on: (eventName: string, listener: (...args: any[]) => void) => 11 | eventEmitter2.on(eventName, listener), 12 | }); 13 | 14 | jest.unstable_mockModule('node:https', () => ({ 15 | get: (url, cb) => { 16 | cb(response()); 17 | return eventEmitter; 18 | }, 19 | })); 20 | 21 | const HttpsServiceConstructor = //@ts-ignore 22 | (await import('../src/services/https.service.js')).HttpsService; 23 | class HttpsService extends HttpsServiceConstructor {} 24 | 25 | describe('Http Service', () => { 26 | let httpsService: HttpsService; 27 | beforeEach(() => { 28 | httpsService = new HttpsService(); 29 | }); 30 | 31 | describe('#get', () => { 32 | beforeEach(() => { 33 | statusCodeMock = 200; 34 | }); 35 | 36 | it('should reject if a error ocurr', (done) => { 37 | const errorMsg = 'test error'; 38 | httpsService 39 | .getJson('http://sampleUrl') 40 | .then() 41 | .catch((error: Error) => { 42 | expect(error.message).toBe(errorMsg); 43 | done(); 44 | }); 45 | eventEmitter.emit('error', new Error(errorMsg)); 46 | }); 47 | 48 | it('should reject if the code of the response indicate error (101)', (done) => { 49 | statusCodeMock = 101; 50 | httpsService 51 | .getJson('http://sampleUrl') 52 | .then() 53 | .catch(() => { 54 | done(); 55 | }); 56 | }); 57 | 58 | it('should reject if the code of the response indicate error (404)', (done) => { 59 | statusCodeMock = 404; 60 | httpsService 61 | .getJson('http://sampleUrl') 62 | .then() 63 | .catch(() => { 64 | done(); 65 | }); 66 | }); 67 | 68 | it('should resolve with all chunks of data on end', (done) => { 69 | const chunks = ['{"key1"', ':"test","ke', 'y2":"p', 'assed"}']; 70 | const expected = { 71 | key1: 'test', 72 | key2: 'passed', 73 | }; 74 | 75 | httpsService.getJson('http://sampleUrl').then((data) => { 76 | expect(data).toEqual(expected); 77 | done(); 78 | }); 79 | 80 | chunks.forEach((chunk) => eventEmitter2.emit('data', chunk)); 81 | eventEmitter2.emit('end'); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /__tests__/logger.service.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { normalize } from 'path'; 3 | 4 | const writeFileSyncMock = jest.fn(); 5 | const renameFileSyncMock = jest.fn(); 6 | const existsSyncMock = jest.fn(); 7 | jest.unstable_mockModule('fs', () => { 8 | return { 9 | writeFileSync: writeFileSyncMock, 10 | existsSync: existsSyncMock, 11 | renameSync: renameFileSyncMock, 12 | default: jest.fn(), 13 | }; 14 | }); 15 | 16 | let osTmpPath = '/tmpDir'; 17 | jest.unstable_mockModule('os', () => { 18 | return { 19 | tmpdir: () => osTmpPath, 20 | }; 21 | }); 22 | 23 | const LoggerServiceConstructor = //@ts-ignore 24 | (await import('../src/services/logger.service.js')).LoggerService; 25 | class LoggerService extends LoggerServiceConstructor {} 26 | 27 | describe('LoggerService', () => { 28 | let logger: LoggerService; 29 | let fakeTime = new Date('2026-01-01'); 30 | let fakeTimeEpox = fakeTime.getTime(); 31 | 32 | beforeEach(() => { 33 | logger = new LoggerService(); 34 | jest.useFakeTimers().setSystemTime(fakeTime); 35 | }); 36 | 37 | describe('add to log (info, error)', () => { 38 | it('should add the message to the log with the correct type and timestamp', () => { 39 | expect(logger.get()).toEqual([]); 40 | logger.info('Sample message1'); 41 | logger.error('Sample message2'); 42 | logger.error('Sample message3'); 43 | logger.info('Sample message4'); 44 | expect(logger.get()).toEqual([ 45 | { 46 | type: 'info', 47 | timestamp: fakeTimeEpox, 48 | message: 'Sample message1', 49 | }, 50 | { 51 | type: 'error', 52 | timestamp: fakeTimeEpox, 53 | message: 'Sample message2', 54 | }, 55 | { 56 | type: 'error', 57 | timestamp: fakeTimeEpox, 58 | message: 'Sample message3', 59 | }, 60 | { 61 | type: 'info', 62 | timestamp: fakeTimeEpox, 63 | message: 'Sample message4', 64 | }, 65 | ]); 66 | }); 67 | }); 68 | 69 | describe('get', () => { 70 | it('should get "all" logs (by default or explicit)', () => { 71 | expect(logger.get()).toEqual([]); 72 | logger.info(''); 73 | logger.error(''); 74 | logger.info(''); 75 | 76 | const expected = ['info', 'error', 'info']; 77 | 78 | expect(logger.get().map((entry) => entry.type)).toEqual(expected); 79 | expect(logger.get('all').map((entry) => entry.type)).toEqual(expected); 80 | }); 81 | 82 | it('should get "info" logs', () => { 83 | logger.info(''); 84 | logger.error(''); 85 | logger.info(''); 86 | 87 | const expected = ['info', 'info']; 88 | 89 | expect(logger.get('info').map((entry) => entry.type)).toEqual(expected); 90 | }); 91 | 92 | it('should get "error" logs', () => { 93 | logger.info(''); 94 | logger.error(''); 95 | logger.info(''); 96 | 97 | const expected = ['error']; 98 | 99 | expect(logger.get('error').map((entry) => entry.type)).toEqual(expected); 100 | }); 101 | }); 102 | 103 | describe('getSuggestLogfilePath', () => { 104 | it('the path should includes the os tmp dir', () => { 105 | const path = logger.getSuggestLogFilePath(); 106 | expect(path.includes(normalize('/tmpDir'))).toBeTruthy(); 107 | }); 108 | }); 109 | 110 | describe('LogFile rotation', () => { 111 | it('should not rotate file if not exist', () => { 112 | existsSyncMock.mockReturnValue(false); 113 | const path = logger.getSuggestLogFilePath(); 114 | logger.saveToFile(path); 115 | expect(renameFileSyncMock).not.toBeCalled(); 116 | }); 117 | 118 | it('should rotate file if exist', () => { 119 | existsSyncMock.mockReturnValue(true); 120 | const path = logger.getSuggestLogFilePath(); 121 | logger.saveToFile(path); 122 | const expectedOldPath = path.replace('latest', 'old'); 123 | expect(renameFileSyncMock).toBeCalledWith(path, expectedOldPath); 124 | }); 125 | }); 126 | 127 | describe('saveToFile', () => { 128 | it('shoul write the content of the log to a given file', () => { 129 | const path = '/tmp/npkill-log.log'; 130 | logger.info('hello'); 131 | logger.error('bye'); 132 | logger.info('world'); 133 | const expected = 134 | '[1767225600000](info) hello\n' + 135 | '[1767225600000](error) bye\n' + 136 | '[1767225600000](info) world\n'; 137 | 138 | logger.saveToFile(path); 139 | expect(writeFileSyncMock).toBeCalledWith(path, expected); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /__tests__/main.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | const controllerConstructorMock = jest.fn(); 4 | const constructorInitMock = jest.fn(); 5 | const linuxServiceConstructorMock = jest.fn(); 6 | const mackServiceConstructorMock = jest.fn(); 7 | const windowsServiceConstructorMock = jest.fn(); 8 | const fileWorkerServiceConstructorMock = jest.fn(); 9 | 10 | jest.mock('../src/controller', () => ({ 11 | Controller: controllerConstructorMock.mockImplementation(() => ({ 12 | init: constructorInitMock, 13 | })), 14 | })); 15 | 16 | //#region mock of files services 17 | jest.unstable_mockModule('../src/services/files/linux-files.service', () => ({ 18 | LinuxFilesService: linuxServiceConstructorMock, 19 | })); 20 | jest.unstable_mockModule('../src/services/files/mac-files.service', () => ({ 21 | MacFilesService: mackServiceConstructorMock, 22 | })); 23 | jest.unstable_mockModule('../src/services/files/windows-files.service', () => ({ 24 | WindowsFilesService: windowsServiceConstructorMock, 25 | })); 26 | jest.unstable_mockModule('../src/services/files/files.worker.service', () => ({ 27 | FileWorkerService: fileWorkerServiceConstructorMock, 28 | })); 29 | //#endregion 30 | 31 | describe('main', () => { 32 | let main; 33 | beforeEach(() => { 34 | jest.resetModules(); 35 | linuxServiceConstructorMock.mockClear(); 36 | mackServiceConstructorMock.mockClear(); 37 | windowsServiceConstructorMock.mockClear(); 38 | }); 39 | 40 | describe('Should load correct File Service based on the OS', () => { 41 | const SERVICES_MOCKS = [ 42 | linuxServiceConstructorMock, 43 | mackServiceConstructorMock, 44 | windowsServiceConstructorMock, 45 | ]; 46 | 47 | const mockOs = (platform: NodeJS.Platform) => { 48 | Object.defineProperty(process, 'platform', { 49 | value: platform, 50 | }); 51 | }; 52 | 53 | const testIfServiceIsIstanciated = async (serviceMock) => { 54 | let servicesThatShouldNotBeCalled = [...SERVICES_MOCKS].filter( 55 | (service) => service !== serviceMock, 56 | ); 57 | expect(serviceMock).toBeCalledTimes(0); 58 | main = await import('../src/main'); 59 | expect(serviceMock).toBeCalledTimes(1); 60 | servicesThatShouldNotBeCalled.forEach((service) => 61 | expect(service).toBeCalledTimes(0), 62 | ); 63 | }; 64 | 65 | it('when OS is Linux', async () => { 66 | mockOs('linux'); 67 | await testIfServiceIsIstanciated(linuxServiceConstructorMock); 68 | }); 69 | 70 | it('when OS is MAC', async () => { 71 | mockOs('darwin'); 72 | await testIfServiceIsIstanciated(mackServiceConstructorMock); 73 | }); 74 | 75 | it('when OS is Windows', async () => { 76 | mockOs('win32'); 77 | await testIfServiceIsIstanciated(windowsServiceConstructorMock); 78 | }); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /__tests__/result.service.test.ts: -------------------------------------------------------------------------------- 1 | import { IFolder } from '../src/interfaces/folder.interface.js'; 2 | import { ResultsService } from '../src/services/results.service.js'; 3 | 4 | describe('Result Service', () => { 5 | let resultService; 6 | beforeEach(() => { 7 | resultService = new ResultsService(); 8 | }); 9 | 10 | describe('#addResult', () => { 11 | it('should add folder if that is the first', () => { 12 | const newResult: IFolder = { 13 | path: 'path', 14 | size: 5, 15 | status: 'live', 16 | modificationTime: 0, 17 | isDangerous: false, 18 | }; 19 | const resultExpected = [newResult]; 20 | resultService.addResult(newResult); 21 | expect(resultService.results).toMatchObject(resultExpected); 22 | }); 23 | it('should add folders', () => { 24 | const newResults: IFolder[] = [ 25 | { 26 | path: 'path', 27 | size: 1, 28 | status: 'live', 29 | modificationTime: 0, 30 | isDangerous: false, 31 | }, 32 | { 33 | path: 'path2', 34 | size: 2, 35 | status: 'deleted', 36 | modificationTime: 0, 37 | isDangerous: false, 38 | }, 39 | { 40 | path: 'path3', 41 | size: 3, 42 | status: 'live', 43 | modificationTime: 0, 44 | isDangerous: false, 45 | }, 46 | ]; 47 | 48 | const resultExpected = newResults; 49 | 50 | newResults.forEach((result) => resultService.addResult(result)); 51 | expect(resultService.results).toMatchObject(resultExpected); 52 | }); 53 | }); 54 | 55 | describe('#sortResults', () => { 56 | let mockResults: IFolder[]; 57 | beforeEach(() => { 58 | mockResults = [ 59 | { 60 | path: 'pathd', 61 | size: 5, 62 | status: 'live', 63 | modificationTime: 0, 64 | isDangerous: false, 65 | }, 66 | { 67 | path: 'patha', 68 | size: 6, 69 | status: 'live', 70 | modificationTime: 0, 71 | isDangerous: false, 72 | }, 73 | { 74 | path: 'pathc', 75 | size: 16, 76 | status: 'live', 77 | modificationTime: 0, 78 | isDangerous: false, 79 | }, 80 | { 81 | path: 'pathcc', 82 | size: 0, 83 | status: 'deleted', 84 | modificationTime: 0, 85 | isDangerous: false, 86 | }, 87 | { 88 | path: 'pathb', 89 | size: 3, 90 | status: 'deleted', 91 | modificationTime: 0, 92 | isDangerous: false, 93 | }, 94 | { 95 | path: 'pathz', 96 | size: 8, 97 | status: 'live', 98 | modificationTime: 0, 99 | isDangerous: false, 100 | }, 101 | ]; 102 | 103 | resultService.results = [...mockResults]; 104 | }); 105 | 106 | it('should sort by path', () => { 107 | const expectResult = [ 108 | { 109 | path: 'patha', 110 | size: 6, 111 | status: 'live', 112 | modificationTime: 0, 113 | isDangerous: false, 114 | }, 115 | { 116 | path: 'pathb', 117 | size: 3, 118 | status: 'deleted', 119 | modificationTime: 0, 120 | isDangerous: false, 121 | }, 122 | { 123 | path: 'pathc', 124 | size: 16, 125 | status: 'live', 126 | modificationTime: 0, 127 | isDangerous: false, 128 | }, 129 | { 130 | path: 'pathcc', 131 | size: 0, 132 | status: 'deleted', 133 | modificationTime: 0, 134 | isDangerous: false, 135 | }, 136 | { 137 | path: 'pathd', 138 | size: 5, 139 | status: 'live', 140 | modificationTime: 0, 141 | isDangerous: false, 142 | }, 143 | { 144 | path: 'pathz', 145 | size: 8, 146 | status: 'live', 147 | modificationTime: 0, 148 | isDangerous: false, 149 | }, 150 | ]; 151 | 152 | resultService.sortResults('path'); 153 | expect(resultService.results).toMatchObject(expectResult); 154 | }); 155 | it('should sort by size', () => { 156 | const expectResult = [ 157 | { 158 | path: 'pathc', 159 | size: 16, 160 | status: 'live', 161 | }, 162 | { 163 | path: 'pathz', 164 | size: 8, 165 | status: 'live', 166 | }, 167 | { 168 | path: 'patha', 169 | size: 6, 170 | status: 'live', 171 | }, 172 | { 173 | path: 'pathd', 174 | size: 5, 175 | status: 'live', 176 | }, 177 | { 178 | path: 'pathb', 179 | size: 3, 180 | status: 'deleted', 181 | }, 182 | { 183 | path: 'pathcc', 184 | size: 0, 185 | status: 'deleted', 186 | }, 187 | ]; 188 | 189 | resultService.sortResults('size'); 190 | expect(resultService.results).toMatchObject(expectResult); 191 | }); 192 | it('should not sort if method dont exist', () => { 193 | const expectResult = mockResults; 194 | 195 | resultService.sortResults('color'); 196 | expect(resultService.results).toMatchObject(expectResult); 197 | }); 198 | }); 199 | 200 | describe('#getStats', () => { 201 | let mockResults: IFolder[]; 202 | beforeEach(() => { 203 | mockResults = [ 204 | { 205 | path: 'pathd', 206 | size: 5, 207 | status: 'live', 208 | modificationTime: 0, 209 | isDangerous: false, 210 | }, 211 | { 212 | path: 'patha', 213 | size: 6, 214 | status: 'deleted', 215 | modificationTime: 0, 216 | isDangerous: false, 217 | }, 218 | { 219 | path: 'pathc', 220 | size: 16, 221 | status: 'live', 222 | modificationTime: 0, 223 | isDangerous: false, 224 | }, 225 | { 226 | path: 'pathcc', 227 | size: 0, 228 | status: 'deleted', 229 | modificationTime: 0, 230 | isDangerous: false, 231 | }, 232 | { 233 | path: 'pathb', 234 | size: 3, 235 | status: 'deleted', 236 | modificationTime: 0, 237 | isDangerous: false, 238 | }, 239 | { 240 | path: 'pathz', 241 | size: 8, 242 | status: 'live', 243 | modificationTime: 0, 244 | isDangerous: false, 245 | }, 246 | ]; 247 | 248 | resultService.results = [...mockResults]; 249 | }); 250 | 251 | it('should get stats of results', () => { 252 | const expectResult = { 253 | spaceReleased: '9.00 GB', 254 | totalSpace: '38.00 GB', 255 | }; 256 | 257 | const stats = resultService.getStats(); 258 | expect(stats).toMatchObject(expectResult); 259 | }); 260 | }); 261 | }); 262 | -------------------------------------------------------------------------------- /__tests__/spinner.service.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | import { SpinnerService } from '../src/services/spinner.service.js'; 3 | 4 | describe('Spinner Service', () => { 5 | let spinnerService: SpinnerService; 6 | 7 | beforeEach(() => { 8 | spinnerService = new SpinnerService(); 9 | }); 10 | 11 | describe('#setSpinner', () => { 12 | // it('should set spinner passed by argument', () => {}); 13 | 14 | it('should reset count', () => { 15 | const resetFn = (spinnerService.reset = jest.fn()); 16 | spinnerService.setSpinner([]); 17 | expect(resetFn).toBeCalled(); 18 | }); 19 | }); 20 | 21 | describe('#nextFrame', () => { 22 | it('should get next frame in orden every call', () => { 23 | spinnerService.setSpinner(['a ', ' b ', ' c']); 24 | expect(spinnerService.nextFrame()).toBe('a '); 25 | expect(spinnerService.nextFrame()).toBe(' b '); 26 | expect(spinnerService.nextFrame()).toBe(' c'); 27 | expect(spinnerService.nextFrame()).toBe('a '); 28 | }); 29 | }); 30 | 31 | describe('#reset', () => { 32 | it('should set to first frame', () => { 33 | spinnerService.setSpinner(['1', '2', '3']); 34 | expect(spinnerService.nextFrame()).toBe('1'); 35 | expect(spinnerService.nextFrame()).toBe('2'); 36 | spinnerService.reset(); 37 | expect(spinnerService.nextFrame()).toBe('1'); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /__tests__/ui/results.ui.test.ts: -------------------------------------------------------------------------------- 1 | import { ConsoleService, FileService } from '../../src/services/index.js'; 2 | 3 | import { IFolder } from '../../src/interfaces/folder.interface.js'; 4 | import { ResultsService } from '../../src/services/results.service.js'; 5 | import { jest } from '@jest/globals'; 6 | 7 | const stdoutWriteMock = jest.fn() as any; 8 | 9 | const originalProcess = process; 10 | const mockProcess = () => { 11 | global.process = { 12 | ...originalProcess, 13 | stdout: { 14 | write: stdoutWriteMock, 15 | rows: 30, 16 | columns: 80, 17 | } as NodeJS.WriteStream & { 18 | fd: 1; 19 | }, 20 | }; 21 | }; 22 | 23 | const ResultsUiConstructor = //@ts-ignore 24 | (await import('../../src/ui/components/results.ui.js')).ResultsUi; 25 | class ResultsUi extends ResultsUiConstructor {} 26 | 27 | describe('ResultsUi', () => { 28 | let resultsUi: ResultsUi; 29 | 30 | let resultsServiceMock: ResultsService = { 31 | results: [], 32 | } as unknown as ResultsService; 33 | 34 | let consoleServiceMock: ConsoleService = { 35 | shortenText: (text) => text, 36 | } as unknown as ConsoleService; 37 | 38 | let fileServiceMock: FileService = { 39 | convertGBToMB: (value) => value, 40 | } as unknown as FileService; 41 | 42 | beforeEach(() => { 43 | mockProcess(); 44 | resultsServiceMock.results = []; 45 | resultsUi = new ResultsUi( 46 | resultsServiceMock, 47 | consoleServiceMock, 48 | fileServiceMock, 49 | ); 50 | }); 51 | 52 | afterEach(() => { 53 | jest.resetAllMocks(); 54 | }); 55 | 56 | describe('render', () => { 57 | it('should render results', () => { 58 | resultsServiceMock.results = [ 59 | { 60 | path: 'path/folder/1', 61 | size: 1, 62 | status: 'live', 63 | }, 64 | { 65 | path: 'path/folder/2', 66 | size: 1, 67 | status: 'live', 68 | }, 69 | ] as IFolder[]; 70 | 71 | resultsUi.render(); 72 | 73 | // With stringContaining we can ignore the terminal color codes. 74 | expect(stdoutWriteMock).toBeCalledWith( 75 | expect.stringContaining('path/folder/1'), 76 | ); 77 | expect(stdoutWriteMock).toBeCalledWith( 78 | expect.stringContaining('path/folder/2'), 79 | ); 80 | }); 81 | 82 | it("should't render results if it is not visible", () => { 83 | const populateResults = () => { 84 | for (let i = 0; i < 100; i++) { 85 | resultsServiceMock.results.push({ 86 | path: `path/folder/${i}`, 87 | size: 1, 88 | status: 'live', 89 | } as IFolder); 90 | } 91 | }; 92 | 93 | populateResults(); 94 | resultsUi.render(); 95 | 96 | // With stringContaining we can ignore the terminal color codes. 97 | expect(stdoutWriteMock).toBeCalledWith( 98 | expect.stringContaining('path/folder/1'), 99 | ); 100 | expect(stdoutWriteMock).not.toBeCalledWith( 101 | expect.stringContaining('path/folder/64'), 102 | ); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /__tests__/update.service.test.ts: -------------------------------------------------------------------------------- 1 | import { jest } from '@jest/globals'; 2 | 3 | import { HttpsService } from '../src/services/https.service.js'; 4 | import { UpdateService } from '../src/services/update.service.js'; 5 | 6 | describe('update Service', () => { 7 | let updateService: UpdateService; 8 | let httpsService: HttpsService; 9 | 10 | beforeEach(() => { 11 | httpsService = new HttpsService(); 12 | updateService = new UpdateService(httpsService); 13 | }); 14 | 15 | describe('#isUpdated', () => { 16 | const cases = [ 17 | { 18 | isUpdated: false, 19 | localVersion: '2.3.6', 20 | remoteVersion: '2.4.0', 21 | }, 22 | { 23 | isUpdated: true, 24 | localVersion: '2.3.6', 25 | remoteVersion: '2.3.6', 26 | }, 27 | { 28 | isUpdated: true, 29 | localVersion: '2.3.6', 30 | remoteVersion: '2.3.6-0', 31 | }, 32 | { 33 | isUpdated: true, 34 | localVersion: '2.3.6', 35 | remoteVersion: '2.3.6-2', 36 | }, 37 | { 38 | isUpdated: true, 39 | localVersion: '2.3.6-1', 40 | remoteVersion: '2.3.6-2', 41 | }, 42 | { 43 | isUpdated: true, 44 | localVersion: '2.3.6', 45 | remoteVersion: '0.3.6', 46 | }, 47 | { 48 | isUpdated: true, 49 | localVersion: '2.3.6', 50 | remoteVersion: '0.2.1', 51 | }, 52 | { 53 | isUpdated: true, 54 | localVersion: '2.3.6', 55 | remoteVersion: '2.2.1', 56 | }, 57 | { 58 | isUpdated: true, 59 | localVersion: '2.3.6', 60 | remoteVersion: '2.3.5', 61 | }, 62 | { 63 | isUpdated: true, 64 | localVersion: '2.3.6', 65 | remoteVersion: '0.2.53', 66 | }, 67 | { 68 | isUpdated: false, 69 | localVersion: '2.3.6', 70 | remoteVersion: '2.3.61', 71 | }, 72 | { 73 | isUpdated: false, 74 | localVersion: '2.3.6', 75 | remoteVersion: '2.3.59', 76 | }, 77 | { 78 | isUpdated: false, 79 | localVersion: '2.3.6', 80 | remoteVersion: '2.3.7', 81 | }, 82 | { 83 | isUpdated: false, 84 | localVersion: '2.3.6-0', 85 | remoteVersion: '4.74.452', 86 | }, 87 | { 88 | isUpdated: true, 89 | localVersion: '0.10.0', 90 | remoteVersion: '0.9.0', 91 | }, 92 | { 93 | isUpdated: true, 94 | localVersion: '0.11.0', 95 | remoteVersion: '0.9.0', 96 | }, 97 | ]; 98 | 99 | cases.forEach((cas) => { 100 | it(`should check the local version ${cas.localVersion} is up to date with the remote ${cas.remoteVersion}`, (done) => { 101 | const mockResponse = `{"last-recomended-version": "${cas.remoteVersion}"}`; 102 | jest 103 | .spyOn(httpsService, 'getJson') 104 | .mockImplementation(() => Promise.resolve(JSON.parse(mockResponse))); 105 | 106 | updateService 107 | .isUpdated(cas.localVersion) 108 | .then((isUpdated) => { 109 | expect(isUpdated).toBe(cas.isUpdated); 110 | done(); 111 | }) 112 | .catch(done); 113 | }); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /docs/RELEASE.md: -------------------------------------------------------------------------------- 1 | # How to release a new version 2 | 3 | ### 1. Ensure the latest changes are available 4 | 5 | ```bash 6 | git checkout develop 7 | git pull 8 | git checkout main 9 | git pull 10 | ``` 11 | 12 | ### 2. Merge develop into main 13 | 14 | ```bash 15 | git merge develop --no-ff 16 | ``` 17 | 18 | ### 3. Run the release script... 19 | 20 | ```bash 21 | # Ensure that the dependencies match those in package.json 22 | rm -rf node_modules; npm i 23 | npm run release 24 | ``` 25 | 26 | The release script takes care of 2 things: 27 | 28 | - Execute the compilation tasks specified in the gulp file (transpiling, copying the binary, etc.) and leaving the artifact ready in lib 29 | 30 | - Start the interactive release process itself. 31 | 32 | ### 4. Pick version type (major, minor, path) 33 | 34 | ### 5. Test the new release. 35 | -------------------------------------------------------------------------------- /docs/create-demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # This script create a example node_modules files 4 | # only for demo purpose. 5 | # 6 | 7 | BASE_PATH="$HOME/allStartHere" 8 | 9 | function create(){ 10 | projectName=$1 11 | fileSize=$2 12 | fakeModificationDate=$(expr $(date +"%s") - $(shuf -i 0-5259486 -n 1)) # 2 month of margin 13 | mkdir -p "$BASE_PATH/$projectName/node_modules" 14 | head -c ${fileSize}MB /dev/zero > "$BASE_PATH/$projectName/node_modules/a" 15 | touch -a -m -d @$fakeModificationDate "$BASE_PATH/$projectName/sample_npkill_file" 16 | } 17 | 18 | 19 | create 'secret-project' '58' 20 | create 'Angular Tuto' '812' 21 | create 'testest' '43' 22 | create 'archived/Half Dead 3' '632' 23 | create 'cats' '384' 24 | create 'navigations/001' '89' 25 | create 'navigations/002' '88' 26 | create 'navigations/003' '23' 27 | create 'more-cats' '371' 28 | create 'projects/hero-sample' '847' 29 | create 'projects/awesome-project' '131' 30 | create 'projects/calculator/frontend' '883' 31 | create 'projects/caluclator/backend' '244' 32 | create 'games/buscaminas' '349' 33 | create 'games/archived/cards' '185' 34 | create 'archived/weather-api' '151' 35 | create 'kiwis-are-awesome' '89' 36 | create 'projects/projects-of-projects/trucs' '237' 37 | create 'projects/projects-of-projects/conversor-divisas' '44' 38 | create 'projects/vue/hello-world' '160' 39 | create 'projects/vue/Quantic stuff' '44' 40 | -------------------------------------------------------------------------------- /docs/npkill proto-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 43 | 45 | 46 | 48 | image/svg+xml 49 | 51 | 52 | 53 | 54 | 55 | 73 | 78 | 84 | 87 | 93 | 99 | 100 | 106 | 112 | 118 | 124 | 130 | 131 | 136 | 142 | 148 | 154 | 160 | 161 | 162 | -------------------------------------------------------------------------------- /docs/npkill-alpha-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voidcosmos/npkill/fdb006e88c0a93829f95161b3941e74ec1928419/docs/npkill-alpha-demo.gif -------------------------------------------------------------------------------- /docs/npkill-demo-0.10.0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voidcosmos/npkill/fdb006e88c0a93829f95161b3941e74ec1928419/docs/npkill-demo-0.10.0.gif -------------------------------------------------------------------------------- /docs/npkill-demo-0.3.0.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voidcosmos/npkill/fdb006e88c0a93829f95161b3941e74ec1928419/docs/npkill-demo-0.3.0.gif -------------------------------------------------------------------------------- /docs/npkill-scope-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 69 | 74 | 77 | 84 | 90 | 91 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /docs/npkill-scope-mono.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 46 | 48 | 49 | 51 | image/svg+xml 52 | 54 | 55 | 56 | 57 | 58 | 63 | 69 | 74 | 77 | 84 | 90 | 91 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /docs/npkill-text-clean.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 44 | 46 | 47 | 49 | image/svg+xml 50 | 52 | 53 | 54 | 55 | 56 | 61 | 87 | 92 | 97 | 103 | 108 | 113 | 118 | 119 | 120 | 121 | -------------------------------------------------------------------------------- /docs/npkill-text-outlined.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 47 | 49 | 50 | 52 | image/svg+xml 53 | 55 | 56 | 57 | 58 | 63 | 75 | 87 | 91 | 96 | 101 | 107 | 112 | 117 | 122 | 123 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /gulpfile.mjs: -------------------------------------------------------------------------------- 1 | import gulp from 'gulp'; 2 | import ts from 'gulp-typescript'; 3 | import { deleteAsync as del } from 'del'; 4 | 5 | function clean() { 6 | return del(['./lib']); 7 | } 8 | 9 | function typescript() { 10 | const tsProject = ts.createProject('tsconfig.json'); 11 | return tsProject.src().pipe(tsProject()).js.pipe(gulp.dest('lib')); 12 | } 13 | 14 | function copyTsConfig() { 15 | const files = ['./tsconfig.json']; 16 | return gulp.src(files).pipe(gulp.dest('./lib')); 17 | } 18 | 19 | const buildAll = gulp.series(clean, typescript, copyTsConfig); 20 | 21 | gulp.task('default', buildAll); 22 | gulp.task('typescript', typescript); 23 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from 'ts-jest'; 2 | 3 | const config: JestConfigWithTsJest = { 4 | preset: 'ts-jest/presets/default-esm', 5 | testEnvironment: 'node', 6 | extensionsToTreatAsEsm: ['.ts'], 7 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', 8 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*)\\.js$': '$1', 11 | }, 12 | // moduleNameMapper: { 13 | // '^@core/(.*)$': '/src/$1', 14 | // '^@services/(.*)$': '/src/services/$1', 15 | // '^@interfaces/(.*)$': '/src/interfaces/$1', 16 | // '^@constants/(.*)$': '/src/constants/$1', 17 | // }, 18 | // transform: { 19 | // '^.+\\.(t|j)sx?$': ['ts-jest', { useESM: true }], 20 | // }, 21 | transform: { 22 | '^.+\\.(t|j)sx?$': ['ts-jest', { useESM: true }], 23 | }, 24 | }; 25 | 26 | export default config; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "npkill", 3 | "version": "0.12.2", 4 | "description": "List any node_modules directories in your system, as well as the space they take up. You can then select which ones you want to erase to free up space.", 5 | "exports": "./lib/index.js", 6 | "type": "module", 7 | "engines": { 8 | "node": ">=14.0.0" 9 | }, 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "bin": { 14 | "npkill": "lib/index.js" 15 | }, 16 | "author": "Nya Garcia & Juan Torres", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/zaldih/npkill" 20 | }, 21 | "license": "MIT", 22 | "keywords": [ 23 | "cli", 24 | "free up space", 25 | "npm", 26 | "node", 27 | "modules", 28 | "clean", 29 | "tool", 30 | "delete", 31 | "find", 32 | "interactive" 33 | ], 34 | "files": [ 35 | "lib/**/*" 36 | ], 37 | "scripts": { 38 | "build": "gulp", 39 | "build-go-bin": "gulp buildGo", 40 | "start": "node --loader ts-node/esm --no-warnings ./src/index.ts", 41 | "test": "node --experimental-vm-modules --experimental-modules node_modules/jest/bin/jest.js --verbose", 42 | "test:watch": "npm run test -- --watch", 43 | "test:mutant": "stryker run", 44 | "release": "npm run build && np", 45 | "debug": "TS_NODE_FILES=true node --inspect -r ts-node/register ./src/index.ts", 46 | "prepare": "husky install", 47 | "format": "prettier --write ." 48 | }, 49 | "dependencies": { 50 | "ansi-escapes": "^6.2.1", 51 | "colors": "1.4.0", 52 | "get-folder-size": "^4.0.0", 53 | "node-emoji": "^2.1.3", 54 | "open-file-explorer": "^1.0.2", 55 | "rxjs": "^7.8.1" 56 | }, 57 | "devDependencies": { 58 | "@commitlint/config-conventional": "^19.2.2", 59 | "@stryker-mutator/core": "^8.2.6", 60 | "@stryker-mutator/jest-runner": "^8.2.6", 61 | "@types/colors": "^1.2.1", 62 | "@types/gulp": "^4.0.17", 63 | "@types/jest": "^29.5.12", 64 | "@types/node": "^20.12.7", 65 | "@types/rimraf": "^3.0.2", 66 | "@typescript-eslint/eslint-plugin": "^5.62.0", 67 | "commitlint": "^19.2.2", 68 | "del": "^7.1.0", 69 | "eslint": "^8.57.0", 70 | "eslint-config-prettier": "^9.1.0", 71 | "eslint-config-standard-with-typescript": "^34.0.1", 72 | "eslint-plugin-import": "^2.29.1", 73 | "eslint-plugin-n": "^15.7.0", 74 | "eslint-plugin-promise": "^6.1.1", 75 | "gulp": "^5.0.0", 76 | "gulp-typescript": "^6.0.0-alpha.1", 77 | "husky": "^9.0.11", 78 | "jest": "^29.7.0", 79 | "lint-staged": "^15.2.2", 80 | "np": "^10.0.3", 81 | "pre-commit": "^1.2.2", 82 | "prettier": "^3.2.5", 83 | "rimraf": "^5.0.5", 84 | "stryker-cli": "^1.0.2", 85 | "ts-jest": "^29.1.2", 86 | "ts-node": "^10.9.2", 87 | "tslint": "^6.1.0", 88 | "typescript": "^5.4.5" 89 | }, 90 | "husky": { 91 | "hooks": { 92 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS", 93 | "pre-commit": "lint-staged" 94 | } 95 | }, 96 | "commitlint": { 97 | "extends": [ 98 | "@commitlint/config-conventional" 99 | ] 100 | }, 101 | "lint-staged": { 102 | "*.{js,ts,css,json,md}": [ 103 | "prettier --write" 104 | ] 105 | }, 106 | "ethereum": "0x7668e86c8bdb52034606db5aa0d2d4d73a0d4259" 107 | } 108 | -------------------------------------------------------------------------------- /src/constants/cli.constants.ts: -------------------------------------------------------------------------------- 1 | import { ICliOptions } from '../interfaces/index.js'; 2 | import colors from 'colors'; 3 | 4 | export const OPTIONS: ICliOptions[] = [ 5 | { 6 | arg: ['-c', '--bg-color'], 7 | description: 8 | 'Change row highlight color. Available colors are: blue, cyan, magenta, red, white and yellow. Default is blue.', 9 | name: 'bg-color', 10 | }, 11 | { 12 | arg: ['-d', '--directory'], 13 | description: 14 | 'Set directory from which to start searching. By default, starting-point is .', 15 | name: 'directory', 16 | }, 17 | { 18 | arg: ['-D', '--delete-all'], 19 | description: 'Auto-delete all node_modules folders that are found.', 20 | name: 'delete-all', 21 | }, 22 | { 23 | arg: ['-y'], 24 | description: 'Avoid displaying a warning when executing --delete-all.', 25 | name: 'yes', 26 | }, 27 | { 28 | arg: ['-e', '--hide-errors'], 29 | description: 'Hide errors if any.', 30 | name: 'hide-errors', 31 | }, 32 | { 33 | arg: ['-E', '--exclude'], 34 | description: 35 | 'Exclude directories from search (directory list must be inside double quotes "", each directory separated by "," ) Example: "ignore1, ignore2"', 36 | name: 'exclude', 37 | }, 38 | { 39 | arg: ['-f', '--full'], 40 | description: 41 | 'Start searching from the home of the user (example: "/home/user" in linux).', 42 | name: 'full-scan', 43 | }, 44 | { 45 | arg: ['-gb'], 46 | description: 'Show folder size in Gigabytes', 47 | name: 'gb', 48 | }, 49 | { 50 | arg: ['-h', '--help', '?'], 51 | description: 'Show this help page, with all options.', 52 | name: 'help', 53 | }, 54 | { 55 | arg: ['-nu', '--no-check-update'], 56 | description: 'Dont check for updates on startup.', 57 | name: 'no-check-updates', 58 | }, 59 | { 60 | arg: ['-s', '--sort'], 61 | description: 62 | 'Sort results by: size, path or last-mod (last time the most recent file was modified in the workspace)', 63 | name: 'sort-by', 64 | }, 65 | { 66 | arg: ['-t', '--target'], 67 | description: 68 | "Specify the name of the directory you want to search for (by default, it's node_modules)", 69 | name: 'target-folder', 70 | }, 71 | { 72 | arg: ['-x', '--exclude-hidden-directories'], 73 | description: 'Exclude hidden directories ("dot" directories) from search.', 74 | name: 'exclude-hidden-directories', 75 | }, 76 | { 77 | arg: ['--dry-run'], 78 | description: 79 | 'It does not delete anything (will simulate it with a random delay).', 80 | name: 'dry-run', 81 | }, 82 | { 83 | arg: ['-v', '--version'], 84 | description: 'Show version.', 85 | name: 'version', 86 | }, 87 | ]; 88 | 89 | export const HELP_HEADER = `This tool allows you to list any node_modules directories in your system, as well as the space they take up. You can then select which ones you want to erase to free up space. 90 | ┌------ CONTROLS -------------------- 91 | 🭲 SPACE, DEL: delete selected result 92 | 🭲 Cursor UP, k: move up 93 | 🭲 Cursor DOWN, j: move down 94 | 🭲 h, d, Ctrl+d, PgUp: move one page down 95 | 🭲 l, u, Ctrl+u, PgDown: move one page up 96 | 🭲 home, end: move to the first and last result 97 | 🭲 o: open the parent directory of the selected result 98 | 🭲 e: show errors popup, next page`; 99 | 100 | export const HELP_PROGRESSBAR = ` ------- PROGRESS BAR -------------------- 101 | The progress bar provides information on the search process. It has 3 parts differentiated by colors. 102 | 103 | ┌ (green) Results ready (stats calculated). 104 | 🭲 ┌ (white) Directories examined. 105 | 🭲 🭲 ┌ (gray) Directories pending to be analyzed. 106 | ${colors.green('▀▀▀▀▀▀▀')}${colors.white('▀▀▀▀')}${colors.gray('▀▀▀▀▀▀▀▀▀▀▀')} 107 | `; 108 | 109 | export const HELP_FOOTER = 110 | 'Not all node_modules are bad! Some applications (like vscode, Discord, etc) need those dependencies to work. If their directory is deleted, the application will probably break (until the dependencies are reinstalled). NPKILL will show you these directories by highlighting them ⚠️'; 111 | 112 | export const COLORS = { 113 | cyan: 'bgCyan', 114 | magenta: 'bgMagenta', 115 | red: 'bgRed', 116 | white: 'bgWhite', 117 | yellow: 'bgYellow', 118 | }; 119 | -------------------------------------------------------------------------------- /src/constants/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cli.constants.js'; 2 | export * from './main.constants.js'; 3 | export * from './messages.constants.js'; 4 | export * from './sort.result.js'; 5 | export * from './spinner.constants.js'; 6 | export * from './update.constants.js'; 7 | export * from './recursive-rmdir-node-support.constants.js'; 8 | -------------------------------------------------------------------------------- /src/constants/main.constants.ts: -------------------------------------------------------------------------------- 1 | import { IConfig, IUiPosition } from '../interfaces/index.js'; 2 | 3 | export const MIN_CLI_COLUMNS_SIZE = 60; 4 | export const CURSOR_SIMBOL = '~>'; 5 | export const WIDTH_OVERFLOW = '...'; 6 | export const DEFAULT_SIZE = '0 MB'; 7 | export const DECIMALS_SIZE = 2; 8 | export const OVERFLOW_CUT_FROM = 11; 9 | 10 | export const DEFAULT_CONFIG: IConfig = { 11 | backgroundColor: 'bgBlue', 12 | warningColor: 'brightYellow', 13 | checkUpdates: true, 14 | deleteAll: false, 15 | dryRun: false, 16 | exclude: ['.git'], 17 | excludeHiddenDirectories: false, 18 | folderSizeInGB: false, 19 | maxSimultaneousSearch: 6, 20 | showErrors: true, 21 | sortBy: '', 22 | targetFolder: 'node_modules', 23 | yes: false, 24 | }; 25 | 26 | export const MARGINS = { 27 | FOLDER_COLUMN_END: 19, 28 | FOLDER_COLUMN_START: 1, 29 | FOLDER_SIZE_COLUMN: 10, 30 | ROW_RESULTS_START: 8, 31 | }; 32 | 33 | export const UI_HELP = { 34 | X_COMMAND_OFFSET: 3, 35 | X_DESCRIPTION_OFFSET: 27, 36 | Y_OFFSET: 2, 37 | }; 38 | 39 | export const UI_POSITIONS = { 40 | FOLDER_SIZE_HEADER: { x: -1, y: 7 }, // x is calculated in controller 41 | INITIAL: { x: 0, y: 0 }, 42 | VERSION: { x: 38, y: 5 }, 43 | DRY_RUN_NOTICE: { x: 1, y: 6 }, 44 | NEW_UPDATE_FOUND: { x: 42, y: 0 }, 45 | SPACE_RELEASED: { x: 50, y: 3 }, 46 | STATUS: { x: 50, y: 4 }, 47 | STATUS_BAR: { x: 50, y: 5 }, 48 | PENDING_TASKS: { x: 50, y: 6 }, //Starting position. It will then be replaced. 49 | TOTAL_SPACE: { x: 50, y: 2 }, 50 | ERRORS_COUNT: { x: 50, y: 1 }, 51 | TUTORIAL_TIP: { x: 1, y: 7 }, 52 | WARNINGS: { x: 0, y: 9 }, 53 | }; 54 | 55 | // export const VALID_KEYS: string[] = [ 56 | // 'up', // Move up 57 | // 'down', // Move down 58 | // 'space', // Delete 59 | // 'j', // Move down 60 | // 'k', // Move up 61 | // 'h', // Move page down 62 | // 'l', // Move page up 63 | // 'u', // Move page up 64 | // 'd', // Move page down 65 | // 'pageup', 66 | // 'pagedown', 67 | // 'home', // Move to the first result 68 | // 'end', // Move to the last result 69 | // 'e', // Show errors 70 | // ]; 71 | 72 | export const BANNER = `----- __ .__.__ .__ 73 | - ____ ______ | | _|__| | | | 74 | ------ / \\\\____ \\| |/ / | | | | 75 | ---- | | \\ |_> > <| | |_| |__ 76 | -- |___| / __/|__|_ \\__|____/____/ 77 | ------- \\/|__| \\/ 78 | `; 79 | 80 | export const STREAM_ENCODING = 'utf8'; 81 | -------------------------------------------------------------------------------- /src/constants/messages.constants.ts: -------------------------------------------------------------------------------- 1 | export const HELP_MSGS = { 2 | BASIC_USAGE: ' CURSORS for select - SPACE to delete', 3 | }; 4 | 5 | export const INFO_MSGS = { 6 | DELETED_FOLDER: '[DELETED] ', 7 | DELETING_FOLDER: '[..deleting..] ', 8 | ERROR_DELETING_FOLDER: '[ ERROR ] ', 9 | HEADER_COLUMNS: 'Last_mod Size', 10 | HELP_TITLE: ' NPKILL HELP ', 11 | MIN_CLI_CLOMUNS: 12 | 'Oh no! The terminal is too narrow. Please, ' + 13 | 'enlarge it (This will be fixed in future versions. Disclose the inconveniences)', 14 | NEW_UPDATE_FOUND: 'New version found! npm i -g npkill for update.', 15 | NO_TTY: 16 | 'Oh no! Npkill does not support this terminal (TTY is required). This ' + 17 | 'is a bug, which has to be fixed. Please try another command interpreter ' + 18 | '(for example, CMD in windows)', 19 | NO_VALID_SORT_NAME: 'Invalid sort option. Available: path | size | last-mod', 20 | STARTING: 'Initializing ', 21 | SEARCHING: 'Searching ', 22 | CALCULATING_STATS: 'Calculating stats ', 23 | FATAL_ERROR: 'Fatal error ', 24 | SEARCH_COMPLETED: 'Search completed ', 25 | SPACE_RELEASED: 'Space saved: ', 26 | TOTAL_SPACE: 'Releasable space: ', 27 | DRY_RUN: 'Dry run mode', 28 | DELETE_ALL_WARNING: 29 | ' --delete-all may have undesirable effects and\n' + 30 | ' delete dependencies needed by some applications.\n' + 31 | ' Recommended to use -x and preview with --dry-run.\n\n' + 32 | ' Press y to continue.\n\n' + 33 | ' pass -y to not show this next time', 34 | }; 35 | 36 | export const ERROR_MSG = { 37 | CANT_DELETE_FOLDER: 38 | 'The directory cannot be deleted. Do you have permission?', 39 | CANT_GET_REMOTE_VERSION: 'Couldnt check for updates', 40 | }; 41 | -------------------------------------------------------------------------------- /src/constants/recursive-rmdir-node-support.constants.ts: -------------------------------------------------------------------------------- 1 | export const RECURSIVE_RMDIR_NODE_VERSION_SUPPORT = { major: 12, minor: 10 }; 2 | export const RM_NODE_VERSION_SUPPORT = { major: 14, minor: 14 }; 3 | 4 | export const RECURSIVE_RMDIR_IGNORED_ERROR_CODES: string[] = [ 5 | 'ENOTEMPTY', 6 | 'EEXIST', 7 | ]; 8 | -------------------------------------------------------------------------------- /src/constants/sort.result.ts: -------------------------------------------------------------------------------- 1 | import { IFolder } from '../interfaces/folder.interface.js'; 2 | 3 | export const FOLDER_SORT = { 4 | path: (a: IFolder, b: IFolder) => (a.path > b.path ? 1 : -1), 5 | size: (a: IFolder, b: IFolder) => (a.size < b.size ? 1 : -1), 6 | 'last-mod': (a: IFolder, b: IFolder) => { 7 | if (a.modificationTime === b.modificationTime) { 8 | return FOLDER_SORT.path(a, b); 9 | } 10 | 11 | if (a.modificationTime === null && b.modificationTime !== null) { 12 | return 1; 13 | } 14 | 15 | if (b.modificationTime === null && a.modificationTime !== null) { 16 | return -1; 17 | } 18 | 19 | return a.modificationTime - b.modificationTime; 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/constants/spinner.constants.ts: -------------------------------------------------------------------------------- 1 | export const SPINNER_INTERVAL = 70; 2 | export const SPINNERS = { 3 | SPRING: [ 4 | '⠈', 5 | '⠉', 6 | '⠋', 7 | '⠓', 8 | '⠒', 9 | '⠐', 10 | '⠐', 11 | '⠒', 12 | '⠖', 13 | '⠦', 14 | '⠤', 15 | '⠠', 16 | '⠠', 17 | '⠤', 18 | '⠦', 19 | '⠖', 20 | '⠒', 21 | '⠐', 22 | '⠐', 23 | '⠒', 24 | '⠓', 25 | '⠋', 26 | '⠉', 27 | '⠈', 28 | ], 29 | W10: [ 30 | '⢀⠀', 31 | '⡀⠀', 32 | '⠄⠀', 33 | '⢂⠀', 34 | '⡂⠀', 35 | '⠅⠀', 36 | '⢃⠀', 37 | '⡃⠀', 38 | '⠍⠀', 39 | '⢋⠀', 40 | '⡋⠀', 41 | '⠍⠁', 42 | '⢋⠁', 43 | '⡋⠁', 44 | '⠍⠉', 45 | '⠋⠉', 46 | '⠋⠉', 47 | '⠉⠙', 48 | '⠉⠙', 49 | '⠉⠩', 50 | '⠈⢙', 51 | '⠈⡙', 52 | '⢈⠩', 53 | '⡀⢙', 54 | '⠄⡙', 55 | '⢂⠩', 56 | '⡂⢘', 57 | '⠅⡘', 58 | '⢃⠨', 59 | '⡃⢐', 60 | '⠍⡐', 61 | '⢋⠠', 62 | '⡋⢀', 63 | '⠍⡁', 64 | '⢋⠁', 65 | '⡋⠁', 66 | '⠍⠉', 67 | '⠋⠉', 68 | '⠋⠉', 69 | '⠉⠙', 70 | '⠉⠙', 71 | '⠉⠩', 72 | '⠈⢙', 73 | '⠈⡙', 74 | '⠈⠩', 75 | '⠀⢙', 76 | '⠀⡙', 77 | '⠀⠩', 78 | '⠀⢘', 79 | '⠀⡘', 80 | '⠀⠨', 81 | '⠀⢐', 82 | '⠀⡐', 83 | '⠀⠠', 84 | '⠀⢀', 85 | '⠀⡀', 86 | ], 87 | }; 88 | -------------------------------------------------------------------------------- /src/constants/status.constants.ts: -------------------------------------------------------------------------------- 1 | import colors from 'colors'; 2 | 3 | export const BAR_PARTS = { 4 | bg: colors.gray('▀'), 5 | searchTask: colors.white('▀'), 6 | calculatingTask: colors.blue('▀'), 7 | completed: colors.green('▀'), 8 | }; 9 | 10 | export const BAR_WIDTH = 25; 11 | -------------------------------------------------------------------------------- /src/constants/update.constants.ts: -------------------------------------------------------------------------------- 1 | export const VERSION_CHECK_DIRECTION = 'https://npkill.js.org/version.json'; 2 | export const VERSION_KEY = 'last-recomended-version'; 3 | -------------------------------------------------------------------------------- /src/constants/workers.constants.ts: -------------------------------------------------------------------------------- 1 | export const MAX_WORKERS = 8; 2 | // More PROCS improve the speed of the search in the worker, 3 | // but it will greatly increase the maximum ram usage. 4 | export const MAX_PROCS = 100; 5 | export enum EVENTS { 6 | startup = 'startup', 7 | alive = 'alive', 8 | exploreConfig = 'exploreConfig', 9 | explore = 'explore', 10 | scanResult = 'scanResult', 11 | } 12 | -------------------------------------------------------------------------------- /src/dirname.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'url'; 2 | import { dirname } from 'path'; 3 | 4 | const _filename = fileURLToPath(import.meta.url); 5 | const _dirname = dirname(_filename); 6 | 7 | export default _dirname; 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import main from './main.js'; 4 | main(); 5 | -------------------------------------------------------------------------------- /src/interfaces/cli-options.interface.ts: -------------------------------------------------------------------------------- 1 | export interface ICliOptions { 2 | arg: string[]; 3 | name: string; 4 | description: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/command-keys.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IKeysCommand { 2 | up: () => void; 3 | down: () => void; 4 | space: () => void; 5 | j: () => void; 6 | k: () => void; 7 | h: () => void; 8 | l: () => void; 9 | d: () => void; 10 | u: () => void; 11 | pageup: () => void; 12 | pagedown: () => void; 13 | home: () => void; 14 | end: () => void; 15 | e: () => void; 16 | execute: (command: string, params?: string[]) => number; 17 | } 18 | -------------------------------------------------------------------------------- /src/interfaces/config.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IConfig { 2 | backgroundColor: string; 3 | warningColor: string; 4 | checkUpdates: boolean; 5 | deleteAll: boolean; 6 | folderSizeInGB: boolean; 7 | maxSimultaneousSearch: number; 8 | showErrors: boolean; 9 | sortBy: string; 10 | targetFolder: string; 11 | exclude: string[]; 12 | excludeHiddenDirectories: boolean; 13 | dryRun: boolean; 14 | yes: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /src/interfaces/error-callback.interface.ts: -------------------------------------------------------------------------------- 1 | import ErrnoException = NodeJS.ErrnoException; 2 | 3 | export type IErrorCallback = (err?: ErrnoException) => void; 4 | -------------------------------------------------------------------------------- /src/interfaces/file-service.interface.ts: -------------------------------------------------------------------------------- 1 | import { IListDirParams } from '../interfaces/list-dir-params.interface.js'; 2 | import { Observable } from 'rxjs'; 3 | 4 | export interface IFileService { 5 | getFolderSize: (path: string) => Observable; 6 | listDir: (params: IListDirParams) => Observable; 7 | deleteDir: (path: string) => Promise; 8 | fakeDeleteDir: (_path: string) => Promise; 9 | isValidRootFolder: (path: string) => boolean; 10 | convertKbToGB: (kb: number) => number; 11 | convertBytesToKB: (bytes: number) => number; 12 | convertGBToMB: (gb: number) => number; 13 | getFileContent: (path: string) => string; 14 | isSafeToDelete: (path: string, targetFolder: string) => boolean; 15 | isDangerous: (path: string) => boolean; 16 | getRecentModificationInDir: (path: string) => Promise; 17 | getFileStatsInDir: (dirname: string) => Promise; 18 | } 19 | 20 | export interface IFileStat { 21 | path: string; 22 | modificationTime: number; 23 | } 24 | -------------------------------------------------------------------------------- /src/interfaces/folder.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IFolder { 2 | path: string; 3 | size: number; 4 | modificationTime: number; 5 | isDangerous: boolean; 6 | status: 'live' | 'deleting' | 'error-deleting' | 'deleted'; 7 | } 8 | 9 | /* interface IFolderStatus { 10 | [key: string]: 'live' | 'deleting' | 'error-deleting' | 'deleted'; 11 | } */ 12 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cli-options.interface.js'; 2 | export * from './command-keys.interface.js'; 3 | export * from './config.interface.js'; 4 | export * from './file-service.interface.js'; 5 | export * from './folder.interface.js'; 6 | export * from './key-press.interface.js'; 7 | export * from './list-dir-params.interface.js'; 8 | export * from './stats.interface.js'; 9 | export * from './ui-positions.interface.js'; 10 | export * from './version.interface.js'; 11 | export * from './node-version.interface.js'; 12 | export * from './error-callback.interface.js'; 13 | -------------------------------------------------------------------------------- /src/interfaces/key-press.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IKeyPress { 2 | name: string; 3 | meta: boolean; 4 | ctrl: boolean; 5 | shift: boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/interfaces/list-dir-params.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IListDirParams { 2 | path: string; 3 | target: string; 4 | exclude?: string[]; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/node-version.interface.ts: -------------------------------------------------------------------------------- 1 | export interface INodeVersion { 2 | major: number; 3 | minor: number; 4 | patch: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/stats.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IStats { 2 | spaceReleased: string; 3 | totalSpace: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/interfaces/ui-positions.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IPosition { 2 | x: number; 3 | y: number; 4 | } 5 | 6 | export type IUiPosition = Record; 7 | -------------------------------------------------------------------------------- /src/interfaces/version.interface.ts: -------------------------------------------------------------------------------- 1 | export interface IVersion { 2 | major: number; 3 | minor: number; 4 | patch: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/libs/buffer-until.ts: -------------------------------------------------------------------------------- 1 | import { Observable, OperatorFunction, of } from 'rxjs'; 2 | 3 | class Buffer { 4 | values = ''; 5 | 6 | append(value: string): void { 7 | this.values += value; 8 | } 9 | 10 | reset(): void { 11 | this.values = ''; 12 | } 13 | } 14 | 15 | export function bufferUntil( 16 | filter: (buffer: string) => boolean, 17 | resetNotifier: Observable = of(), 18 | ): OperatorFunction { 19 | return function (source$: Observable): Observable { 20 | const buffer = new Buffer(); 21 | 22 | return new Observable((observer) => { 23 | const resetNotifierSubscription = resetNotifier.subscribe(() => 24 | buffer.reset(), 25 | ); 26 | source$.subscribe({ 27 | next: (value: string) => { 28 | buffer.append(value); 29 | 30 | if (filter(buffer.values)) { 31 | observer.next(buffer.values); 32 | buffer.reset(); 33 | } 34 | }, 35 | error: (err) => { 36 | resetNotifierSubscription.unsubscribe(); 37 | observer.error(err); 38 | }, 39 | complete: () => { 40 | resetNotifierSubscription.unsubscribe(); 41 | observer.complete(); 42 | }, 43 | }); 44 | }); 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConsoleService, 3 | HttpsService, 4 | LinuxFilesService, 5 | MacFilesService, 6 | ResultsService, 7 | SpinnerService, 8 | StreamService, 9 | UpdateService, 10 | WindowsFilesService, 11 | } from './services/index.js'; 12 | 13 | import { Controller } from './controller.js'; 14 | import { IFileService } from './interfaces/file-service.interface.js'; 15 | import { FileWorkerService } from './services/files/files.worker.service.js'; 16 | import { UiService } from './services/ui.service.js'; 17 | import { LoggerService } from './services/logger.service.js'; 18 | import { SearchStatus } from './models/search-state.model.js'; 19 | 20 | const getOS = (): NodeJS.Platform => process.platform; 21 | 22 | const OSService = { 23 | linux: LinuxFilesService, 24 | win32: WindowsFilesService, 25 | darwin: MacFilesService, 26 | }; 27 | 28 | const logger = new LoggerService(); 29 | const searchStatus = new SearchStatus(); 30 | 31 | const fileWorkerService = new FileWorkerService(logger, searchStatus); 32 | const streamService: StreamService = new StreamService(); 33 | 34 | const fileService: IFileService = new OSService[getOS()]( 35 | streamService, 36 | fileWorkerService, 37 | ); 38 | 39 | export const controller = new Controller( 40 | logger, 41 | searchStatus, 42 | fileService, 43 | new SpinnerService(), 44 | new ConsoleService(), 45 | new UpdateService(new HttpsService()), 46 | new ResultsService(), 47 | new UiService(), 48 | ); 49 | 50 | export default (): void => controller.init(); 51 | -------------------------------------------------------------------------------- /src/models/search-state.model.ts: -------------------------------------------------------------------------------- 1 | import { WorkerStatus } from '../services'; 2 | 3 | export class SearchStatus { 4 | public pendingSearchTasks = 0; 5 | public completedSearchTasks = 0; 6 | public pendingStatsCalculation = 0; 7 | public completedStatsCalculation = 0; 8 | public resultsFound = 0; 9 | public pendingDeletions = 0; 10 | public workerStatus: WorkerStatus = 'stopped'; 11 | public workersJobs; 12 | 13 | newResult(): void { 14 | this.resultsFound++; 15 | this.pendingStatsCalculation++; 16 | } 17 | 18 | completeStatCalculation(): void { 19 | this.pendingStatsCalculation--; 20 | this.completedStatsCalculation++; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/models/start-parameters.model.ts: -------------------------------------------------------------------------------- 1 | export class StartParameters { 2 | private values: Record = {}; 3 | 4 | add(key: string, value: string | boolean): void { 5 | this.values[key] = value; 6 | } 7 | 8 | isTrue(key: string): boolean { 9 | const value = this.values[key]; 10 | return value !== undefined && (value === true || value !== 'false'); 11 | } 12 | 13 | getString(key: string): string { 14 | const value = this.values[key]; 15 | if (typeof value === 'boolean') { 16 | return value.toString(); 17 | } 18 | 19 | return value; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/services/console.service.ts: -------------------------------------------------------------------------------- 1 | import { OPTIONS, WIDTH_OVERFLOW } from '../constants/index.js'; 2 | 3 | import { ICliOptions } from '../interfaces/cli-options.interface.js'; 4 | import { extname } from 'path'; 5 | import * as readline from 'node:readline'; 6 | import { StartParameters } from '../models/start-parameters.model.js'; 7 | 8 | export class ConsoleService { 9 | getParameters(rawArgv: string[]): StartParameters { 10 | // This needs a refactor, but fck, is a urgent update 11 | const rawProgramArgvs = this.removeSystemArgvs(rawArgv); 12 | const argvs = this.normalizeParams(rawProgramArgvs); 13 | const options: StartParameters = new StartParameters(); 14 | 15 | argvs.forEach((argv, index) => { 16 | if (!this.isArgOption(argv) || !this.isValidOption(argv)) { 17 | return; 18 | } 19 | const nextArgv = argvs[index + 1]; 20 | const option = this.getOption(argv); 21 | 22 | if (option === undefined) { 23 | throw new Error('Invalid option name.'); 24 | } 25 | 26 | const optionName = option.name; 27 | options.add( 28 | optionName, 29 | this.isArgHavingParams(nextArgv) ? nextArgv : true, 30 | ); 31 | }); 32 | 33 | return options; 34 | } 35 | 36 | splitWordsByWidth(text: string, width: number): string[] { 37 | const splitRegex = new RegExp( 38 | `(?![^\\n]{1,${width}}$)([^\\n]{1,${width}})\\s`, 39 | 'g', 40 | ); 41 | const splitText = this.replaceString(text, splitRegex, '$1\n'); 42 | return this.splitData(splitText); 43 | } 44 | 45 | splitData(data: string, separator = '\n'): string[] { 46 | if (data === '') { 47 | return []; 48 | } 49 | return data.split(separator); 50 | } 51 | 52 | replaceString( 53 | text: string, 54 | textToReplace: string | RegExp, 55 | replaceValue: string, 56 | ): string { 57 | return text.replace(textToReplace, replaceValue); 58 | } 59 | 60 | shortenText(text: string, width: number, startCut = 0): string { 61 | if (!this.isValidShortenParams(text, width, startCut)) { 62 | return text; 63 | } 64 | 65 | const startPartB = text.length - (width - startCut - WIDTH_OVERFLOW.length); 66 | const partA = text.substring(startCut, -1); 67 | const partB = text.substring(startPartB, text.length); 68 | 69 | return partA + WIDTH_OVERFLOW + partB; 70 | } 71 | 72 | isRunningBuild(): boolean { 73 | return extname(import.meta.url) === '.js'; 74 | } 75 | 76 | startListenKeyEvents(): void { 77 | readline.emitKeypressEvents(process.stdin); 78 | } 79 | 80 | /** Argvs can be specified for example by 81 | * "--sort size" and "--sort=size". The main function 82 | * expect the parameters as the first form so this 83 | * method convert the second to first. 84 | */ 85 | private normalizeParams(argvs: string[]): string[] { 86 | return argvs.join('=').split('='); 87 | } 88 | 89 | private isValidShortenParams( 90 | text: string, 91 | width: number, 92 | startCut: number, 93 | ): boolean { 94 | return ( 95 | startCut <= width && 96 | text.length >= width && 97 | !this.isNegative(width) && 98 | !this.isNegative(startCut) 99 | ); 100 | } 101 | 102 | private removeSystemArgvs(allArgv: string[]): string[] { 103 | return allArgv.slice(2); 104 | } 105 | 106 | private isArgOption(argv: string): boolean { 107 | return argv.charAt(0) === '-'; 108 | } 109 | 110 | private isArgHavingParams(nextArgv: string): boolean { 111 | return ( 112 | nextArgv !== undefined && nextArgv !== '' && !this.isArgOption(nextArgv) 113 | ); 114 | } 115 | 116 | private isValidOption(arg: string): boolean { 117 | return OPTIONS.some((option) => option.arg.includes(arg)); 118 | } 119 | 120 | private getOption(arg: string): ICliOptions | undefined { 121 | return OPTIONS.find((option) => option.arg.includes(arg)); 122 | } 123 | 124 | private isNegative(numb: number): boolean { 125 | return numb < 0; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/services/files/files.service.ts: -------------------------------------------------------------------------------- 1 | import fs, { accessSync, readFileSync, Stats, statSync } from 'fs'; 2 | import { 3 | IFileService, 4 | IFileStat, 5 | IListDirParams, 6 | } from '../../interfaces/index.js'; 7 | import { readdir, stat } from 'fs/promises'; 8 | import { Observable } from 'rxjs'; 9 | 10 | export abstract class FileService implements IFileService { 11 | abstract getFolderSize(path: string): Observable; 12 | abstract listDir(params: IListDirParams): Observable; 13 | abstract deleteDir(path: string): Promise; 14 | 15 | /** Used for dry-run or testing. */ 16 | async fakeDeleteDir(_path: string): Promise { 17 | const randomDelay = Math.floor(Math.random() * 4000 + 200); 18 | await new Promise((r) => setTimeout(r, randomDelay)); 19 | return true; 20 | } 21 | 22 | isValidRootFolder(path: string): boolean { 23 | let stat: Stats; 24 | try { 25 | stat = statSync(path); 26 | } catch (error) { 27 | throw new Error('The path does not exist.'); 28 | } 29 | 30 | if (!stat.isDirectory()) { 31 | throw new Error('The path must point to a directory.'); 32 | } 33 | 34 | try { 35 | accessSync(path, fs.constants.R_OK); 36 | } catch (error) { 37 | throw new Error('Cannot read the specified path.'); 38 | } 39 | 40 | return true; 41 | } 42 | 43 | convertKbToGB(kb: number): number { 44 | const factorKBtoGB = 1048576; 45 | return kb / factorKBtoGB; 46 | } 47 | 48 | convertBytesToKB(bytes: number): number { 49 | const factorBytestoKB = 1024; 50 | return bytes / factorBytestoKB; 51 | } 52 | 53 | convertGBToMB(gb: number): number { 54 | const factorGBtoMB = 1024; 55 | return gb * factorGBtoMB; 56 | } 57 | 58 | getFileContent(path: string): string { 59 | const encoding = 'utf8'; 60 | return readFileSync(path, encoding); 61 | } 62 | 63 | isSafeToDelete(path: string, targetFolder: string): boolean { 64 | return path.includes(targetFolder); 65 | } 66 | 67 | /** 68 | * > Why dangerous? 69 | * It is probable that if the node_module is included in some hidden directory, it is 70 | * required by some application like "spotify", "vscode" or "Discord" and deleting it 71 | * would imply breaking the application (until the dependencies are reinstalled). 72 | * 73 | * In the case of macOS applications and Windows AppData directory, these locations often contain 74 | * application-specific data or configurations that should not be tampered with. Deleting node_modules 75 | * from these locations could potentially disrupt the normal operation of these applications. 76 | */ 77 | isDangerous(path: string): boolean { 78 | const hiddenFilePattern = /(^|\/)\.[^/.]/g; 79 | const macAppsPattern = /(^|\/)Applications\/[^/]+\.app\//g; 80 | const windowsAppDataPattern = /(^|\\)AppData\\/g; 81 | 82 | return ( 83 | hiddenFilePattern.test(path) || 84 | macAppsPattern.test(path) || 85 | windowsAppDataPattern.test(path) 86 | ); 87 | } 88 | 89 | async getRecentModificationInDir(path: string): Promise { 90 | const files = await this.getFileStatsInDir(path); 91 | const sorted = files.sort( 92 | (a, b) => b.modificationTime - a.modificationTime, 93 | ); 94 | return sorted.length > 0 ? sorted[0].modificationTime : -1; 95 | } 96 | 97 | async getFileStatsInDir(dirname: string): Promise { 98 | let files: IFileStat[] = []; 99 | const items = await readdir(dirname, { withFileTypes: true }); 100 | 101 | for (const item of items) { 102 | if (item.isDirectory()) { 103 | if (item.name === 'node_modules') { 104 | continue; 105 | } 106 | files = [ 107 | ...files, 108 | ...(await this.getFileStatsInDir(`${dirname}/${item.name}`).catch( 109 | () => [], 110 | )), 111 | ]; 112 | } else { 113 | const path = `${dirname}/${item.name}`; 114 | const fileStat = await stat(path); 115 | 116 | files.push({ path, modificationTime: fileStat.mtimeMs / 1000 }); 117 | } 118 | } 119 | 120 | return files; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/services/files/files.worker.service.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | import { dirname, extname } from 'path'; 3 | 4 | import { Worker, MessageChannel, MessagePort } from 'node:worker_threads'; 5 | import { Subject } from 'rxjs'; 6 | import { IListDirParams } from '../../interfaces/index.js'; 7 | import { SearchStatus } from '../../models/search-state.model.js'; 8 | import { LoggerService } from '../logger.service.js'; 9 | import { MAX_WORKERS, EVENTS } from '../../constants/workers.constants.js'; 10 | 11 | export type WorkerStatus = 'stopped' | 'scanning' | 'dead' | 'finished'; 12 | interface WorkerJob { 13 | job: 'explore'; // | 'getSize'; 14 | value: { path: string }; 15 | } 16 | 17 | export interface WorkerMessage { 18 | type: EVENTS; 19 | value: any; 20 | } 21 | 22 | export interface WorkerStats { 23 | pendingSearchTasks: number; 24 | completedSearchTasks: number; 25 | procs: number; 26 | } 27 | 28 | export class FileWorkerService { 29 | private index = 0; 30 | private workers: Worker[] = []; 31 | private workersPendingJobs: number[] = []; 32 | private pendingJobs = 0; 33 | private totalJobs = 0; 34 | private tunnels: MessagePort[] = []; 35 | 36 | constructor( 37 | private readonly logger: LoggerService, 38 | private readonly searchStatus: SearchStatus, 39 | ) {} 40 | 41 | startScan(stream$: Subject, params: IListDirParams): void { 42 | this.instantiateWorkers(this.getOptimalNumberOfWorkers()); 43 | this.listenEvents(stream$); 44 | this.setWorkerConfig(params); 45 | 46 | // Manually add the first job. 47 | this.addJob({ job: 'explore', value: { path: params.path } }); 48 | } 49 | 50 | private listenEvents(stream$: Subject): void { 51 | this.tunnels.forEach((tunnel) => { 52 | tunnel.on('message', (data: WorkerMessage) => { 53 | this.newWorkerMessage(data, stream$); 54 | }); 55 | 56 | this.workers.forEach((worker, index) => { 57 | worker.on('exit', () => { 58 | this.logger.info(`Worker ${index} exited.`); 59 | }); 60 | 61 | worker.on('error', (error) => { 62 | // Respawn worker. 63 | throw error; 64 | }); 65 | }); 66 | }); 67 | } 68 | 69 | private newWorkerMessage( 70 | message: WorkerMessage, 71 | stream$: Subject, 72 | ): void { 73 | const { type, value } = message; 74 | 75 | if (type === EVENTS.scanResult) { 76 | const results: Array<{ path: string; isTarget: boolean }> = value.results; 77 | const workerId: number = value.workerId; 78 | this.workersPendingJobs[workerId] = value.pending; 79 | 80 | results.forEach((result) => { 81 | const { path, isTarget } = result; 82 | if (isTarget) { 83 | stream$.next(path); 84 | } else { 85 | this.addJob({ 86 | job: 'explore', 87 | value: { path }, 88 | }); 89 | } 90 | }); 91 | 92 | this.pendingJobs = this.getPendingJobs(); 93 | this.checkJobComplete(stream$); 94 | } 95 | 96 | if (type === EVENTS.alive) { 97 | this.searchStatus.workerStatus = 'scanning'; 98 | } 99 | } 100 | 101 | /** Jobs are distributed following the round-robin algorithm. */ 102 | private addJob(job: WorkerJob): void { 103 | if (job.job === 'explore') { 104 | const tunnel = this.tunnels[this.index]; 105 | const message: WorkerMessage = { type: EVENTS.explore, value: job.value }; 106 | tunnel.postMessage(message); 107 | this.workersPendingJobs[this.index]++; 108 | this.totalJobs++; 109 | this.pendingJobs++; 110 | this.index = this.index >= this.workers.length - 1 ? 0 : this.index + 1; 111 | } 112 | } 113 | 114 | private checkJobComplete(stream$: Subject): void { 115 | this.updateStats(); 116 | const isCompleted = this.getPendingJobs() === 0; 117 | if (isCompleted) { 118 | this.searchStatus.workerStatus = 'finished'; 119 | stream$.complete(); 120 | void this.killWorkers(); 121 | } 122 | } 123 | 124 | private instantiateWorkers(amount: number): void { 125 | this.logger.info(`Instantiating ${amount} workers..`); 126 | for (let i = 0; i < amount; i++) { 127 | const { port1, port2 } = new MessageChannel(); 128 | const worker = new Worker(this.getWorkerPath()); 129 | this.tunnels.push(port1); 130 | worker.postMessage( 131 | { type: EVENTS.startup, value: { channel: port2, id: i } }, 132 | [port2], // Prevent clone the object and pass the original. 133 | ); 134 | this.workers.push(worker); 135 | this.logger.info(`Worker ${i} instantiated.`); 136 | } 137 | } 138 | 139 | private setWorkerConfig(params: IListDirParams): void { 140 | this.tunnels.forEach((tunnel) => 141 | tunnel.postMessage({ 142 | type: EVENTS.exploreConfig, 143 | value: params, 144 | }), 145 | ); 146 | } 147 | 148 | private async killWorkers(): Promise { 149 | for (let i = 0; i < this.workers.length; i++) { 150 | this.workers[i].removeAllListeners(); 151 | this.tunnels[i].removeAllListeners(); 152 | await this.workers[i] 153 | .terminate() 154 | .catch((error) => this.logger.error(error)); 155 | } 156 | this.workers = []; 157 | this.tunnels = []; 158 | } 159 | 160 | private getPendingJobs(): number { 161 | return this.workersPendingJobs.reduce((acc, x) => x + acc, 0); 162 | } 163 | 164 | private updateStats(): void { 165 | this.searchStatus.pendingSearchTasks = this.pendingJobs; 166 | this.searchStatus.completedSearchTasks = this.totalJobs; 167 | this.searchStatus.workersJobs = this.workersPendingJobs; 168 | } 169 | 170 | private getWorkerPath(): URL { 171 | const actualFilePath = import.meta.url; 172 | const dirPath = dirname(actualFilePath); 173 | // Extension = .ts if is not transpiled. 174 | // Extension = .js if is a build 175 | const extension = extname(actualFilePath); 176 | const workerName = 'files.worker'; 177 | 178 | return new URL(`${dirPath}/${workerName}${extension}`); 179 | } 180 | 181 | private getOptimalNumberOfWorkers(): number { 182 | const cores = os.cpus().length; 183 | // TODO calculate amount of RAM available and take it 184 | // as part on the ecuation. 185 | const numWorkers = cores > MAX_WORKERS ? MAX_WORKERS : cores - 1; 186 | return numWorkers < 1 ? 1 : numWorkers; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/services/files/files.worker.ts: -------------------------------------------------------------------------------- 1 | import { Dir, Dirent } from 'fs'; 2 | import { opendir } from 'fs/promises'; 3 | 4 | import EventEmitter from 'events'; 5 | import { WorkerMessage } from './files.worker.service'; 6 | import { join } from 'path'; 7 | import { MessagePort, parentPort } from 'node:worker_threads'; 8 | import { IListDirParams } from '../../interfaces'; 9 | import { EVENTS, MAX_PROCS } from '../../constants/workers.constants.js'; 10 | 11 | enum ETaskOperation { 12 | 'explore', 13 | 'getSize', 14 | } 15 | interface Task { 16 | operation: ETaskOperation; 17 | path: string; 18 | } 19 | 20 | (() => { 21 | let id = 0; 22 | let fileWalker: FileWalker; 23 | let tunnel: MessagePort; 24 | 25 | if (parentPort === null) { 26 | throw new Error('Worker must be spawned from a parent thread.'); 27 | } 28 | 29 | parentPort.on('message', (message: WorkerMessage) => { 30 | if (message?.type === EVENTS.startup) { 31 | id = message.value.id; 32 | tunnel = message.value.channel; 33 | fileWalker = new FileWalker(); 34 | initTunnelListeners(); 35 | initFileWalkerListeners(); 36 | notifyWorkerReady(); 37 | } 38 | }); 39 | 40 | function notifyWorkerReady(): void { 41 | tunnel.postMessage({ 42 | type: EVENTS.alive, 43 | value: null, 44 | }); 45 | } 46 | 47 | function initTunnelListeners(): void { 48 | tunnel.on('message', (message: WorkerMessage) => { 49 | if (message?.type === EVENTS.exploreConfig) { 50 | fileWalker.setSearchConfig(message.value); 51 | } 52 | 53 | if (message?.type === EVENTS.explore) { 54 | fileWalker.enqueueTask(message.value.path); 55 | } 56 | }); 57 | } 58 | 59 | function initFileWalkerListeners(): void { 60 | fileWalker.events.on('newResult', ({ results }) => { 61 | tunnel.postMessage({ 62 | type: EVENTS.scanResult, 63 | value: { results, workerId: id, pending: fileWalker.pendingJobs }, 64 | }); 65 | }); 66 | } 67 | })(); 68 | 69 | class FileWalker { 70 | readonly events = new EventEmitter(); 71 | private searchConfig: IListDirParams = { 72 | path: '', 73 | target: '', 74 | exclude: [], 75 | }; 76 | 77 | private readonly taskQueue: Task[] = []; 78 | private completedTasks = 0; 79 | private procs = 0; 80 | 81 | setSearchConfig(params: IListDirParams): void { 82 | this.searchConfig = params; 83 | } 84 | 85 | enqueueTask(path: string): void { 86 | this.taskQueue.push({ path, operation: ETaskOperation.explore }); 87 | this.processQueue(); 88 | } 89 | 90 | private async run(path: string): Promise { 91 | this.updateProcs(1); 92 | 93 | try { 94 | const dir = await opendir(path); 95 | await this.analizeDir(path, dir); 96 | } catch (_) { 97 | this.completeTask(); 98 | } 99 | } 100 | 101 | private async analizeDir(path: string, dir: Dir): Promise { 102 | const results = []; 103 | let entry: Dirent | null = null; 104 | while ((entry = await dir.read().catch(() => null)) != null) { 105 | this.newDirEntry(path, entry, results); 106 | } 107 | 108 | this.events.emit('newResult', { results }); 109 | 110 | await dir.close(); 111 | this.completeTask(); 112 | 113 | if (this.taskQueue.length === 0 && this.procs === 0) { 114 | this.completeAll(); 115 | } 116 | } 117 | 118 | private newDirEntry(path: string, entry: Dirent, results: any[]): void { 119 | const subpath = join(path, entry.name); 120 | const shouldSkip = !entry.isDirectory() || this.isExcluded(subpath); 121 | if (shouldSkip) { 122 | return; 123 | } 124 | 125 | results.push({ 126 | path: subpath, 127 | isTarget: this.isTargetFolder(entry.name), 128 | }); 129 | } 130 | 131 | private isExcluded(path: string): boolean { 132 | if (this.searchConfig.exclude === undefined) { 133 | return false; 134 | } 135 | 136 | for (let i = 0; i < this.searchConfig.exclude.length; i++) { 137 | const excludeString = this.searchConfig.exclude[i]; 138 | if (path.includes(excludeString)) { 139 | return true; 140 | } 141 | } 142 | 143 | return false; 144 | } 145 | 146 | private isTargetFolder(path: string): boolean { 147 | // return basename(path) === this.searchConfig.target; 148 | return path === this.searchConfig.target; 149 | } 150 | 151 | private completeTask(): void { 152 | this.updateProcs(-1); 153 | this.processQueue(); 154 | this.completedTasks++; 155 | } 156 | 157 | private updateProcs(value: number): void { 158 | this.procs += value; 159 | } 160 | 161 | private processQueue(): void { 162 | while (this.procs < MAX_PROCS && this.taskQueue.length > 0) { 163 | const path = this.taskQueue.shift()?.path; 164 | if (path === undefined || path === '') { 165 | return; 166 | } 167 | 168 | // Ignore as other mechanisms (pending/completed tasks) are used to 169 | // check the progress of this. 170 | this.run(path).then( 171 | () => {}, 172 | () => {}, 173 | ); 174 | } 175 | } 176 | 177 | private completeAll(): void { 178 | // Any future action. 179 | } 180 | 181 | /* get stats(): WorkerStats { 182 | return { 183 | pendingSearchTasks: this.taskQueue.length, 184 | completedSearchTasks: this.completedTasks, 185 | procs: this.procs, 186 | }; 187 | } */ 188 | 189 | get pendingJobs(): number { 190 | return this.taskQueue.length; 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/services/files/index.ts: -------------------------------------------------------------------------------- 1 | export * from './files.service.js'; 2 | export * from './files.worker.service.js'; 3 | export * from './linux-files.service.js'; 4 | export * from './mac-files.service.js'; 5 | export * from './windows-files.service.js'; 6 | -------------------------------------------------------------------------------- /src/services/files/linux-files.service.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process'; 2 | import { Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | import { UnixFilesService } from './unix-files.service.js'; 5 | 6 | export class LinuxFilesService extends UnixFilesService { 7 | getFolderSize(path: string): Observable { 8 | const du = spawn('du', ['-sk', path]); 9 | const cut = spawn('cut', ['-f', '1']); 10 | du.stdout.pipe(cut.stdin); 11 | 12 | // const command = spawn('sh', ['-c', `du -sk ${path} | cut -f 1`]); 13 | // return this.streamService.getStream(command).pipe(map((size) => +size)); 14 | // 15 | return this.streamService.getStream(cut).pipe(map((size) => +size)); 16 | // const stream$ = new BehaviorSubject(null); 17 | // this.fileWorkerService.getSize(stream$, path); 18 | // this.dirSize(path).then((result) => { 19 | // stream$.next(result / 1024); 20 | // }); 21 | // return stream$; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/services/files/mac-files.service.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { spawn } from 'child_process'; 3 | import { map } from 'rxjs/operators'; 4 | import { UnixFilesService } from './unix-files.service.js'; 5 | 6 | export class MacFilesService extends UnixFilesService { 7 | getFolderSize(path: string): Observable { 8 | const du = spawn('du', ['-sk', path]); 9 | const cut = spawn('cut', ['-f', '1']); 10 | 11 | du.stdout.pipe(cut.stdin); 12 | 13 | return this.streamService.getStream(cut).pipe(map((size) => +size)); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/services/files/unix-files.service.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | 3 | import { FileService } from './files.service.js'; 4 | import { IListDirParams } from '../../interfaces/index.js'; 5 | import { Observable, Subject } from 'rxjs'; 6 | import { StreamService } from '../stream.service.js'; 7 | import { FileWorkerService } from './files.worker.service.js'; 8 | 9 | export abstract class UnixFilesService extends FileService { 10 | constructor( 11 | protected streamService: StreamService, 12 | protected fileWorkerService: FileWorkerService, 13 | ) { 14 | super(); 15 | } 16 | 17 | abstract override getFolderSize(path: string): Observable; 18 | 19 | listDir(params: IListDirParams): Observable { 20 | const stream$ = new Subject(); 21 | this.fileWorkerService.startScan(stream$, params); 22 | return stream$; 23 | } 24 | 25 | async deleteDir(path: string): Promise { 26 | return new Promise((resolve, reject) => { 27 | const command = `rm -rf "${path}"`; 28 | exec(command, (error, stdout, stderr) => { 29 | if (error !== null) { 30 | reject(error); 31 | return; 32 | } 33 | if (stderr !== '') { 34 | reject(stderr); 35 | return; 36 | } 37 | resolve(true); 38 | }); 39 | }); 40 | } 41 | 42 | protected prepareFindArgs(params: IListDirParams): string[] { 43 | const { path, target, exclude } = params; 44 | let args: string[] = [path]; 45 | 46 | if (exclude !== undefined && exclude.length > 0) { 47 | args = [...args, this.prepareExcludeArgs(exclude)].flat(); 48 | } 49 | 50 | args = [...args, '-name', target, '-prune']; 51 | 52 | return args; 53 | } 54 | 55 | protected prepareExcludeArgs(exclude: string[]): string[] { 56 | const excludeDirs = exclude.map((dir: string) => [ 57 | '-not', 58 | '(', 59 | '-name', 60 | dir, 61 | '-prune', 62 | ')', 63 | ]); 64 | return excludeDirs.flat(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/services/files/windows-files.service.ts: -------------------------------------------------------------------------------- 1 | import getFolderSize from 'get-folder-size'; 2 | 3 | import { StreamService } from '../index.js'; 4 | 5 | import { Subject, Observable } from 'rxjs'; 6 | import { FileService } from './files.service.js'; 7 | import { WindowsStrategyManager } from '../../strategies/windows-remove-dir.strategy.js'; 8 | import { FileWorkerService } from './files.worker.service.js'; 9 | import { IListDirParams } from '../../interfaces/list-dir-params.interface.js'; 10 | 11 | export class WindowsFilesService extends FileService { 12 | private readonly windowsStrategyManager: WindowsStrategyManager = 13 | new WindowsStrategyManager(); 14 | 15 | constructor( 16 | private readonly streamService: StreamService, 17 | protected fileWorkerService: FileWorkerService, 18 | ) { 19 | super(); 20 | } 21 | 22 | getFolderSize(path: string): Observable { 23 | return new Observable((observer) => { 24 | getFolderSize.loose(path).then((size) => { 25 | observer.next(super.convertBytesToKB(size)); 26 | observer.complete(); 27 | }); 28 | }); 29 | } 30 | 31 | listDir(params: IListDirParams): Observable { 32 | const stream$ = new Subject(); 33 | this.fileWorkerService.startScan(stream$, params); 34 | return stream$; 35 | } 36 | 37 | async deleteDir(path: string): Promise { 38 | return this.windowsStrategyManager.deleteDir(path); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/services/https.service.ts: -------------------------------------------------------------------------------- 1 | import * as https from 'node:https'; 2 | 3 | export class HttpsService { 4 | async getJson(url: string): Promise> { 5 | return new Promise((resolve, reject) => { 6 | const fail = (err: Error): void => { 7 | reject(err); 8 | }; 9 | 10 | const request = https.get(url, (res) => { 11 | if (!this.isCorrectResponse(res.statusCode ?? -1)) { 12 | const error = new Error(res.statusMessage ?? 'Unknown error'); 13 | fail(error); 14 | return; 15 | } 16 | 17 | res.setEncoding('utf8'); 18 | let body = ''; 19 | res.on('data', (data: string) => { 20 | body += data; 21 | }); 22 | res.on('end', () => { 23 | resolve(JSON.parse(body)); 24 | }); 25 | }); 26 | 27 | request.on('error', (error) => fail(error)); 28 | }); 29 | } 30 | 31 | private isCorrectResponse(statusCode: number): boolean { 32 | const correctRangeStart = 200; 33 | const correctRangeEnd = 299; 34 | return statusCode >= correctRangeStart && statusCode <= correctRangeEnd; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './console.service.js'; 2 | export * from './https.service.js'; 3 | export * from './results.service.js'; 4 | export * from './spinner.service.js'; 5 | export * from './stream.service.js'; 6 | export * from './update.service.js'; 7 | 8 | export * from './files/index.js'; 9 | -------------------------------------------------------------------------------- /src/services/logger.service.ts: -------------------------------------------------------------------------------- 1 | import { tmpdir } from 'os'; 2 | import { existsSync, renameSync, writeFileSync } from 'fs'; 3 | import { basename, dirname, join } from 'path'; 4 | 5 | interface LogEntry { 6 | type: 'info' | 'error'; 7 | timestamp: number; 8 | message: string; 9 | } 10 | 11 | const LATEST_TAG = 'latest'; 12 | const OLD_TAG = 'old'; 13 | 14 | export class LoggerService { 15 | private log: LogEntry[] = []; 16 | 17 | info(message: string): void { 18 | this.addToLog({ 19 | type: 'info', 20 | timestamp: this.getTimestamp(), 21 | message, 22 | }); 23 | } 24 | 25 | error(message: string): void { 26 | this.addToLog({ 27 | type: 'error', 28 | timestamp: this.getTimestamp(), 29 | message, 30 | }); 31 | } 32 | 33 | get(type: 'all' | 'info' | 'error' = 'all'): LogEntry[] { 34 | if (type === 'all') { 35 | return this.log; 36 | } 37 | 38 | return this.log.filter((entry) => entry.type === type); 39 | } 40 | 41 | saveToFile(path: string): void { 42 | const convertTime = (timestamp: number): number => timestamp; 43 | 44 | const content: string = this.log.reduce((log, actual) => { 45 | const line = `[${convertTime(actual.timestamp)}](${actual.type}) ${ 46 | actual.message 47 | }\n`; 48 | return log + line; 49 | }, ''); 50 | 51 | this.rotateLogFile(path); 52 | writeFileSync(path, content); 53 | } 54 | 55 | getSuggestLogFilePath(): string { 56 | const filename = `npkill-${LATEST_TAG}.log`; 57 | return join(tmpdir(), filename); 58 | } 59 | 60 | private rotateLogFile(newLogPath: string): void { 61 | if (!existsSync(newLogPath)) { 62 | return; // Rotation is not necessary 63 | } 64 | const basePath = dirname(newLogPath); 65 | const logName = basename(newLogPath); 66 | const oldLogName = logName.replace(LATEST_TAG, OLD_TAG); 67 | const oldLogPath = join(basePath, oldLogName); 68 | renameSync(newLogPath, oldLogPath); 69 | } 70 | 71 | private addToLog(entry: LogEntry): void { 72 | this.log = [...this.log, entry]; 73 | } 74 | 75 | private getTimestamp(): number { 76 | return new Date().getTime(); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/services/results.service.ts: -------------------------------------------------------------------------------- 1 | import { IFolder, IStats } from '../interfaces/index.js'; 2 | import { FOLDER_SORT } from '../constants/sort.result.js'; 3 | 4 | export class ResultsService { 5 | results: IFolder[] = []; 6 | 7 | addResult(result: IFolder): void { 8 | this.results = [...this.results, result]; 9 | } 10 | 11 | sortResults(method: string): void { 12 | this.results = this.results.sort(FOLDER_SORT[method]); 13 | } 14 | 15 | getStats(): IStats { 16 | let spaceReleased = 0; 17 | 18 | const totalSpace = this.results.reduce((total, folder) => { 19 | if (folder.status === 'deleted') { 20 | spaceReleased += folder.size; 21 | } 22 | 23 | return total + folder.size; 24 | }, 0); 25 | 26 | return { 27 | spaceReleased: `${spaceReleased.toFixed(2)} GB`, 28 | totalSpace: `${totalSpace.toFixed(2)} GB`, 29 | }; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/services/spinner.service.ts: -------------------------------------------------------------------------------- 1 | export class SpinnerService { 2 | private spinner: string[] = []; 3 | private count = -1; 4 | 5 | setSpinner(spinner: string[]): void { 6 | this.spinner = spinner; 7 | this.reset(); 8 | } 9 | 10 | nextFrame(): string { 11 | this.updateCount(); 12 | return this.spinner[this.count]; 13 | } 14 | 15 | reset(): void { 16 | this.count = -1; 17 | } 18 | 19 | private updateCount(): void { 20 | if (this.isLastFrame()) { 21 | this.count = 0; 22 | } else { 23 | ++this.count; 24 | } 25 | } 26 | 27 | private isLastFrame(): boolean { 28 | return this.count === this.spinner.length - 1; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/services/stream.service.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams } from 'child_process'; 2 | import { Observable } from 'rxjs'; 3 | import { STREAM_ENCODING } from '../constants/index.js'; 4 | 5 | export class StreamService { 6 | streamToObservable(stream: ChildProcessWithoutNullStreams): Observable { 7 | const { stdout, stderr } = stream; 8 | 9 | return new Observable((observer) => { 10 | const dataHandler = (data): void => observer.next(data); 11 | const bashErrorHandler = (error): void => 12 | observer.error({ ...error, bash: true }); 13 | const errorHandler = (error): void => observer.error(error); 14 | const endHandler = (): void => observer.complete(); 15 | 16 | stdout.addListener('data', dataHandler); 17 | stdout.addListener('error', errorHandler); 18 | stdout.addListener('end', endHandler); 19 | 20 | stderr.addListener('data', bashErrorHandler); 21 | stderr.addListener('error', errorHandler); 22 | 23 | return () => { 24 | stdout.removeListener('data', dataHandler); 25 | stdout.removeListener('error', errorHandler); 26 | stdout.removeListener('end', endHandler); 27 | 28 | stderr.removeListener('data', bashErrorHandler); 29 | stderr.removeListener('error', errorHandler); 30 | }; 31 | }); 32 | } 33 | 34 | getStream(child: ChildProcessWithoutNullStreams): Observable { 35 | this.setEncoding(child, STREAM_ENCODING); 36 | return this.streamToObservable(child); 37 | } 38 | 39 | private setEncoding( 40 | child: ChildProcessWithoutNullStreams, 41 | encoding: BufferEncoding, 42 | ): void { 43 | child.stdout.setEncoding(encoding); 44 | child.stderr.setEncoding(encoding); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/services/ui.service.ts: -------------------------------------------------------------------------------- 1 | import ansiEscapes from 'ansi-escapes'; 2 | import { Position, BaseUi } from '../ui/index.js'; 3 | 4 | export class UiService { 5 | stdin: NodeJS.ReadStream = process.stdin; 6 | // public stdout: NodeJS.WriteStream = process.stdout; 7 | uiComponents: BaseUi[] = []; 8 | 9 | setRawMode(set = true): void { 10 | this.stdin.setRawMode(set); 11 | process.stdin.resume(); 12 | } 13 | 14 | setCursorVisible(visible: boolean): void { 15 | const instruction = visible 16 | ? ansiEscapes.cursorShow 17 | : ansiEscapes.cursorHide; 18 | this.print(instruction); 19 | } 20 | 21 | add(component: BaseUi): void { 22 | this.uiComponents.push(component); 23 | } 24 | 25 | renderAll(): void { 26 | this.clear(); 27 | this.uiComponents.forEach((component) => { 28 | if (component.visible) { 29 | component.render(); 30 | } 31 | }); 32 | } 33 | 34 | clear(): void { 35 | this.print(ansiEscapes.clearTerminal); 36 | } 37 | 38 | print(text: string): void { 39 | process.stdout.write.bind(process.stdout)(text); 40 | } 41 | 42 | printAt(message: string, position: Position): void { 43 | this.setCursorAt(position); 44 | this.print(message); 45 | } 46 | 47 | setCursorAt({ x, y }: Position): void { 48 | this.print(ansiEscapes.cursorTo(x, y)); 49 | } 50 | 51 | clearLine(row: number): void { 52 | this.printAt(ansiEscapes.eraseLine, { x: 0, y: row }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/services/update.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | VERSION_CHECK_DIRECTION, 3 | VERSION_KEY, 4 | } from '../constants/update.constants.js'; 5 | 6 | import { HttpsService } from './https.service.js'; 7 | 8 | export class UpdateService { 9 | constructor(private readonly httpsService: HttpsService) {} 10 | 11 | /** 12 | * Check if localVersion is greater or equal to remote version 13 | * ignoring the pre-release tag. ex: 1.3.12 = 1.3.12-21 14 | */ 15 | async isUpdated(localVersion: string): Promise { 16 | const removePreReaseTag = (value: string): string => value.split('-')[0]; 17 | 18 | const localVersionPrepared = removePreReaseTag(localVersion); 19 | const remoteVersion = await this.getRemoteVersion(); 20 | const remoteVersionPrepared = removePreReaseTag(remoteVersion); 21 | return this.compareVersions(localVersionPrepared, remoteVersionPrepared); 22 | } 23 | 24 | private compareVersions(local: string, remote: string): boolean { 25 | return ( 26 | this.isSameVersion(local, remote) || 27 | this.isLocalVersionGreater(local, remote) 28 | ); 29 | } 30 | 31 | private async getRemoteVersion(): Promise { 32 | const response = await this.httpsService.getJson(VERSION_CHECK_DIRECTION); 33 | return response[VERSION_KEY]; 34 | } 35 | 36 | private isSameVersion(version1: string, version2: string): boolean { 37 | return version1 === version2; 38 | } 39 | 40 | /** Valid to compare versions up to 99999.99999.99999 */ 41 | private isLocalVersionGreater(local: string, remote: string): boolean { 42 | const leadingZeros = (value: string): string => 43 | ('00000' + value).substring(-5); 44 | 45 | const localLeaded = +local.split('.').map(leadingZeros).join(''); 46 | const remoteLeaded = +remote.split('.').map(leadingZeros).join(''); 47 | 48 | return localLeaded >= remoteLeaded; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/strategies/index.ts: -------------------------------------------------------------------------------- 1 | export * from './windows-default.strategy.js'; 2 | export * from './windows-node12.strategy.js'; 3 | export * from './windows-node14.strategy.js'; 4 | -------------------------------------------------------------------------------- /src/strategies/windows-default.strategy.ts: -------------------------------------------------------------------------------- 1 | import { NoParamCallback, lstat, readdir, rmdir, unlink } from 'fs'; 2 | 3 | import { RECURSIVE_RMDIR_IGNORED_ERROR_CODES } from '../constants/index.js'; 4 | import { WindowsStrategy } from './windows-strategy.abstract.js'; 5 | import { join as pathJoin } from 'path'; 6 | 7 | export class WindowsDefaultStrategy extends WindowsStrategy { 8 | remove(dirOrFilePath: string, callback: NoParamCallback): boolean { 9 | lstat(dirOrFilePath, (lstatError, stats) => { 10 | // No such file or directory - Done 11 | if (lstatError !== null && lstatError.code === 'ENOENT') { 12 | callback(null); 13 | return; 14 | } 15 | 16 | if (stats.isDirectory()) { 17 | this.removeDirectory(dirOrFilePath, callback); 18 | return; 19 | } 20 | 21 | unlink(dirOrFilePath, (rmError) => { 22 | // No such file or directory - Done 23 | if (rmError !== null && rmError.code === 'ENOENT') { 24 | callback(null); 25 | return; 26 | } 27 | 28 | if (rmError !== null && rmError.code === 'EISDIR') { 29 | this.removeDirectory(dirOrFilePath, callback); 30 | return; 31 | } 32 | 33 | callback(rmError); 34 | }); 35 | }); 36 | return true; 37 | } 38 | 39 | isSupported(): boolean { 40 | return true; 41 | } 42 | 43 | private removeDirectory(path: string, callback): void { 44 | rmdir(path, (rmDirError) => { 45 | // We ignore certain error codes 46 | // in order to simulate 'recursive' mode 47 | if ( 48 | rmDirError?.code !== undefined && 49 | RECURSIVE_RMDIR_IGNORED_ERROR_CODES.includes(rmDirError.code) 50 | ) { 51 | this.removeChildren(path, callback); 52 | return; 53 | } 54 | 55 | callback(rmDirError); 56 | }); 57 | } 58 | 59 | private removeChildren(path: string, callback): void { 60 | readdir(path, (readdirError, ls) => { 61 | if (readdirError !== null) { 62 | return callback(readdirError); 63 | } 64 | 65 | let contentInDirectory = ls.length; 66 | let done = false; 67 | 68 | // removeDirectory only allows deleting directories 69 | // that has no content inside (empty directory). 70 | if (contentInDirectory === 0) { 71 | rmdir(path, callback); 72 | return; 73 | } 74 | 75 | ls.forEach((dirOrFile) => { 76 | const dirOrFilePath = pathJoin(path, dirOrFile); 77 | 78 | this.remove(dirOrFilePath, (error) => { 79 | if (done) { 80 | return; 81 | } 82 | 83 | if (error !== null) { 84 | done = true; 85 | return callback(error); 86 | } 87 | 88 | contentInDirectory--; 89 | // No more content inside. 90 | // Remove the directory. 91 | if (contentInDirectory === 0) { 92 | rmdir(path, callback); 93 | } 94 | }); 95 | }); 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/strategies/windows-node12.strategy.ts: -------------------------------------------------------------------------------- 1 | import { NoParamCallback, rmdir } from 'fs'; 2 | 3 | import { RECURSIVE_RMDIR_NODE_VERSION_SUPPORT } from '../constants/index.js'; 4 | import { WindowsStrategy } from './windows-strategy.abstract.js'; 5 | 6 | export class WindowsNode12Strategy extends WindowsStrategy { 7 | remove(path: string, callback: NoParamCallback): boolean { 8 | if (this.isSupported()) { 9 | rmdir(path, { recursive: true }, callback); 10 | return true; 11 | } 12 | return this.checkNext(path, callback); 13 | } 14 | 15 | isSupported(): boolean { 16 | return ( 17 | this.major > RECURSIVE_RMDIR_NODE_VERSION_SUPPORT.major || 18 | (this.major === RECURSIVE_RMDIR_NODE_VERSION_SUPPORT.major && 19 | this.minor > RECURSIVE_RMDIR_NODE_VERSION_SUPPORT.minor) 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/strategies/windows-node14.strategy.ts: -------------------------------------------------------------------------------- 1 | import { NoParamCallback, rm } from 'fs'; 2 | 3 | import { RM_NODE_VERSION_SUPPORT } from '../constants/recursive-rmdir-node-support.constants.js'; 4 | import { WindowsStrategy } from './windows-strategy.abstract.js'; 5 | 6 | export class WindowsNode14Strategy extends WindowsStrategy { 7 | remove(path: string, callback: NoParamCallback): boolean { 8 | if (this.isSupported()) { 9 | rm(path, { recursive: true }, callback); 10 | return true; 11 | } 12 | return this.checkNext(path, callback); 13 | } 14 | 15 | isSupported(): boolean { 16 | return ( 17 | this.major > RM_NODE_VERSION_SUPPORT.major || 18 | (this.major === RM_NODE_VERSION_SUPPORT.major && 19 | this.minor > RM_NODE_VERSION_SUPPORT.minor) 20 | ); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/strategies/windows-remove-dir.strategy.ts: -------------------------------------------------------------------------------- 1 | import { 2 | WindowsNode12Strategy, 3 | WindowsNode14Strategy, 4 | WindowsDefaultStrategy, 5 | } from './index.js'; 6 | import { WindowsStrategy } from './windows-strategy.abstract.js'; 7 | 8 | export class WindowsStrategyManager { 9 | async deleteDir(path: string): Promise { 10 | const windowsStrategy: WindowsStrategy = new WindowsNode14Strategy(); 11 | windowsStrategy 12 | .setNextStrategy(new WindowsNode12Strategy()) 13 | .setNextStrategy(new WindowsDefaultStrategy()); 14 | 15 | return new Promise((resolve, reject) => { 16 | windowsStrategy.remove(path, (err) => { 17 | if (err !== null) { 18 | reject(err); 19 | return; 20 | } 21 | resolve(true); 22 | }); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/strategies/windows-strategy.abstract.ts: -------------------------------------------------------------------------------- 1 | import { INodeVersion } from '../interfaces/index.js'; 2 | import { NoParamCallback } from 'fs'; 3 | import { version } from 'process'; 4 | 5 | export abstract class WindowsStrategy { 6 | private next: WindowsStrategy; 7 | protected major: number; 8 | protected minor: number; 9 | 10 | abstract remove(path: string, callback: NoParamCallback): boolean; 11 | abstract isSupported(major: number, minor: number): boolean; 12 | 13 | constructor() { 14 | const { major, minor } = this.getNodeVersion(); 15 | this.major = major; 16 | this.minor = minor; 17 | } 18 | 19 | setNextStrategy(next: WindowsStrategy): WindowsStrategy { 20 | this.next = next; 21 | return next; 22 | } 23 | 24 | protected checkNext(path: string, callback): boolean { 25 | if (this.next === undefined) { 26 | return true; 27 | } 28 | return this.next.remove(path, callback); 29 | } 30 | 31 | private getNodeVersion(): INodeVersion { 32 | const releaseVersionsRegExp: RegExp = /^v(\d{1,2})\.(\d{1,2})\.(\d{1,2})/; 33 | const versionMatch = version.match(releaseVersionsRegExp); 34 | 35 | if (versionMatch === null) { 36 | throw new Error(`Unable to parse Node version: ${version}`); 37 | } 38 | 39 | return { 40 | major: parseInt(versionMatch[1], 10), 41 | minor: parseInt(versionMatch[2], 10), 42 | patch: parseInt(versionMatch[3], 10), 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/ui/base.ui.ts: -------------------------------------------------------------------------------- 1 | import { IKeyPress } from 'src/interfaces'; 2 | import ansiEscapes from 'ansi-escapes'; 3 | 4 | export interface Position { 5 | x: number; 6 | y: number; 7 | } 8 | 9 | export interface InteractiveUi { 10 | onKeyInput: (key: IKeyPress) => void; 11 | } 12 | 13 | export abstract class BaseUi { 14 | public freezed = false; 15 | protected _position: Position; 16 | protected _visible = true; 17 | private readonly stdout: NodeJS.WriteStream = process.stdout; 18 | 19 | protected printAt(message: string, position: Position): void { 20 | this.setCursorAt(position); 21 | this.print(message); 22 | } 23 | 24 | protected setCursorAt({ x, y }: Position): void { 25 | this.print(ansiEscapes.cursorTo(x, y)); 26 | } 27 | 28 | protected print(text: string): void { 29 | if (this.freezed) { 30 | return; 31 | } 32 | process.stdout.write.bind(process.stdout)(text); 33 | } 34 | 35 | protected clearLine(row: number): void { 36 | this.printAt(ansiEscapes.eraseLine, { x: 0, y: row }); 37 | } 38 | 39 | setPosition(position: Position, renderOnSet = true): void { 40 | this._position = position; 41 | 42 | if (renderOnSet) { 43 | this.render(); 44 | } 45 | } 46 | 47 | setVisible(visible: boolean, renderOnSet = true): void { 48 | this._visible = visible; 49 | 50 | if (renderOnSet) { 51 | this.render(); 52 | } 53 | } 54 | 55 | get position(): Position { 56 | return this._position; 57 | } 58 | 59 | get visible(): boolean { 60 | return this._visible; 61 | } 62 | 63 | get terminal(): { columns: number; rows: number } { 64 | return { 65 | columns: this.stdout.columns, 66 | rows: this.stdout.rows, 67 | }; 68 | } 69 | 70 | abstract render(): void; 71 | } 72 | -------------------------------------------------------------------------------- /src/ui/components/general.ui.ts: -------------------------------------------------------------------------------- 1 | // This class in only a intermediate for the refactor. 2 | 3 | import { BaseUi } from '../base.ui.js'; 4 | import colors from 'colors'; 5 | 6 | export class GeneralUi extends BaseUi { 7 | render(): void {} 8 | 9 | printExitMessage(stats: { spaceReleased: string }): void { 10 | const { spaceReleased } = stats; 11 | let exitMessage = `Space released: ${spaceReleased}\n`; 12 | exitMessage += colors['gray']('Thanks for using npkill!\n Like it? Give us a star http://github.com/voidcosmos/npkill\n'); 13 | this.print(exitMessage); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/components/header/header.ui.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BANNER, 3 | UI_POSITIONS, 4 | HELP_MSGS, 5 | INFO_MSGS, 6 | DEFAULT_SIZE, 7 | } from '../../../constants/index.js'; 8 | import { BaseUi } from '../../base.ui.js'; 9 | import colors from 'colors'; 10 | 11 | export class HeaderUi extends BaseUi { 12 | programVersion: string; 13 | isDryRun: boolean; 14 | 15 | render(): void { 16 | // banner and tutorial 17 | this.printAt(BANNER, UI_POSITIONS.INITIAL); 18 | this.renderHeader(); 19 | 20 | if (this.programVersion !== undefined) { 21 | this.printAt(colors.gray(this.programVersion), UI_POSITIONS.VERSION); 22 | } 23 | 24 | if (this.isDryRun) { 25 | this.printAt( 26 | colors.black(colors.bgMagenta(` ${INFO_MSGS.DRY_RUN} `)), 27 | UI_POSITIONS.DRY_RUN_NOTICE, 28 | ); 29 | } 30 | 31 | // Columns headers 32 | this.printAt(colors.bgYellow(colors.black(INFO_MSGS.HEADER_COLUMNS)), { 33 | x: this.terminal.columns - INFO_MSGS.HEADER_COLUMNS.length - 4, 34 | y: UI_POSITIONS.FOLDER_SIZE_HEADER.y, 35 | }); 36 | 37 | // npkill stats 38 | this.printAt( 39 | colors.gray(INFO_MSGS.TOTAL_SPACE + DEFAULT_SIZE), 40 | UI_POSITIONS.TOTAL_SPACE, 41 | ); 42 | this.printAt( 43 | colors.gray(INFO_MSGS.SPACE_RELEASED + DEFAULT_SIZE), 44 | UI_POSITIONS.SPACE_RELEASED, 45 | ); 46 | } 47 | 48 | private renderHeader(): void { 49 | const { columns } = this.terminal; 50 | const spaceToFill = Math.max(0, columns - HELP_MSGS.BASIC_USAGE.length - 2); 51 | const text = HELP_MSGS.BASIC_USAGE + ' '.repeat(spaceToFill); 52 | this.printAt( 53 | colors.yellow(colors.inverse(text)), 54 | UI_POSITIONS.TUTORIAL_TIP, 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ui/components/header/stats.ui.ts: -------------------------------------------------------------------------------- 1 | import { UI_POSITIONS, INFO_MSGS } from '../../../constants/index.js'; 2 | import { BaseUi } from '../../base.ui.js'; 3 | import { ResultsService } from '../../../services/results.service.js'; 4 | import { LoggerService } from '../../../services/logger.service.js'; 5 | import colors from 'colors'; 6 | import { IConfig } from 'src/interfaces/config.interface.js'; 7 | import { IPosition } from 'src/interfaces/ui-positions.interface.js'; 8 | 9 | interface ShowStatProps { 10 | description: string; 11 | value: string; 12 | lastValueKey: 'totalSpace' | 'spaceReleased'; 13 | position: IPosition; 14 | updateColor: 'green' | 'yellow'; 15 | } 16 | 17 | export class StatsUi extends BaseUi { 18 | private lastValues = { 19 | totalSpace: '', 20 | spaceReleased: '', 21 | }; 22 | 23 | private timeouts = { 24 | totalSpace: setTimeout(() => {}), 25 | spaceReleased: setTimeout(() => {}), 26 | }; 27 | 28 | constructor( 29 | private readonly config: IConfig, 30 | private readonly resultsService: ResultsService, 31 | private readonly logger: LoggerService, 32 | ) { 33 | super(); 34 | } 35 | 36 | render(): void { 37 | const { totalSpace, spaceReleased } = this.resultsService.getStats(); 38 | 39 | this.showStat({ 40 | description: INFO_MSGS.TOTAL_SPACE, 41 | value: totalSpace, 42 | lastValueKey: 'totalSpace', 43 | position: UI_POSITIONS.TOTAL_SPACE, 44 | updateColor: 'yellow', 45 | }); 46 | 47 | this.showStat({ 48 | description: INFO_MSGS.SPACE_RELEASED, 49 | value: spaceReleased, 50 | lastValueKey: 'spaceReleased', 51 | position: UI_POSITIONS.SPACE_RELEASED, 52 | updateColor: 'green', 53 | }); 54 | 55 | if (this.config.showErrors) { 56 | this.showErrorsCount(); 57 | } 58 | } 59 | 60 | /** Print the value of the stat and if it is a different value from the 61 | * previous run, highlight it for a while. 62 | */ 63 | private showStat({ 64 | description, 65 | value, 66 | lastValueKey, 67 | position, 68 | updateColor, 69 | }: ShowStatProps): void { 70 | if (value === this.lastValues[lastValueKey]) { 71 | return; 72 | } 73 | 74 | const statPosition = { ...position }; 75 | statPosition.x += description.length; 76 | 77 | // If is first render, initialize. 78 | if (!this.lastValues[lastValueKey]) { 79 | this.printAt(value, statPosition); 80 | this.lastValues[lastValueKey] = value; 81 | return; 82 | } 83 | 84 | this.printAt(colors[updateColor](`${value} ▲`), statPosition); 85 | 86 | if (this.timeouts[lastValueKey]) { 87 | clearTimeout(this.timeouts[lastValueKey]); 88 | } 89 | 90 | this.timeouts[lastValueKey] = setTimeout(() => { 91 | this.printAt(value + ' ', statPosition); 92 | }, 700); 93 | 94 | this.lastValues[lastValueKey] = value; 95 | } 96 | 97 | private showErrorsCount(): void { 98 | const errors = this.logger.get('error').length; 99 | 100 | if (errors === 0) { 101 | return; 102 | } 103 | 104 | const text = `${errors} error${errors > 1 ? 's' : ''}. 'e' to see`; 105 | this.printAt(colors['yellow'](text), { ...UI_POSITIONS.ERRORS_COUNT }); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/ui/components/header/status.ui.ts: -------------------------------------------------------------------------------- 1 | import { BaseUi } from '../../base.ui.js'; 2 | import colors from 'colors'; 3 | import { SpinnerService } from '../../../services/spinner.service.js'; 4 | import { interval, Subject, takeUntil } from 'rxjs'; 5 | import { INFO_MSGS } from '../../../constants/messages.constants.js'; 6 | import { 7 | SPINNERS, 8 | SPINNER_INTERVAL, 9 | } from '../../../constants/spinner.constants.js'; 10 | import { UI_POSITIONS } from '../../../constants/main.constants.js'; 11 | import { SearchStatus } from '../../../models/search-state.model.js'; 12 | import { BAR_PARTS, BAR_WIDTH } from '../../../constants/status.constants.js'; 13 | 14 | export class StatusUi extends BaseUi { 15 | private text = ''; 16 | private barNormalizedWidth = 0; 17 | private barClosing = false; 18 | private showProgressBar = true; 19 | private pendingTasksPosition = { ...UI_POSITIONS.PENDING_TASKS }; 20 | private readonly searchEnd$ = new Subject(); 21 | private readonly SEARCH_STATES = { 22 | stopped: () => this.startingSearch(), 23 | scanning: () => this.continueSearching(), 24 | dead: () => this.fatalError(), 25 | finished: () => this.continueFinishing(), 26 | }; 27 | 28 | constructor( 29 | private readonly spinnerService: SpinnerService, 30 | private readonly searchStatus: SearchStatus, 31 | ) { 32 | super(); 33 | } 34 | 35 | start(): void { 36 | this.spinnerService.setSpinner(SPINNERS.W10); 37 | interval(SPINNER_INTERVAL) 38 | .pipe(takeUntil(this.searchEnd$)) 39 | .subscribe(() => { 40 | this.SEARCH_STATES[this.searchStatus.workerStatus](); 41 | }); 42 | 43 | this.animateProgressBar(); 44 | } 45 | 46 | completeSearch(duration: number): void { 47 | this.searchEnd$.next(true); 48 | this.searchEnd$.complete(); 49 | 50 | this.text = 51 | colors.green(INFO_MSGS.SEARCH_COMPLETED) + colors.gray(`${duration}s`); 52 | this.render(); 53 | setTimeout(() => this.animateClose(), 2000); 54 | } 55 | 56 | render(): void { 57 | this.printAt(this.text, UI_POSITIONS.STATUS); 58 | 59 | if (this.showProgressBar) { 60 | this.renderProgressBar(); 61 | } 62 | 63 | this.renderPendingTasks(); 64 | } 65 | 66 | private renderPendingTasks(): void { 67 | this.clearPendingTasks(); 68 | if (this.searchStatus.pendingDeletions === 0) { 69 | return; 70 | } 71 | 72 | const { pendingDeletions } = this.searchStatus; 73 | const text = pendingDeletions > 1 ? 'pending tasks' : 'pending task '; 74 | this.printAt( 75 | colors.yellow(`${pendingDeletions} ${text}`), 76 | this.pendingTasksPosition, 77 | ); 78 | } 79 | 80 | private clearPendingTasks(): void { 81 | const PENDING_TASK_LENGHT = 17; 82 | this.printAt(' '.repeat(PENDING_TASK_LENGHT), this.pendingTasksPosition); 83 | } 84 | 85 | private renderProgressBar(): void { 86 | const { 87 | pendingSearchTasks, 88 | completedSearchTasks, 89 | completedStatsCalculation, 90 | pendingStatsCalculation, 91 | } = this.searchStatus; 92 | 93 | const proportional = (a: number, b: number, c: number): number => { 94 | if (c === 0) { 95 | return 0; 96 | } 97 | return (a * b) / c; 98 | }; 99 | 100 | const modifier = 101 | this.barNormalizedWidth === 1 102 | ? 1 103 | : // easeInOut formula 104 | -(Math.cos(Math.PI * this.barNormalizedWidth) - 1) / 2; 105 | 106 | const barSearchMax = pendingSearchTasks + completedSearchTasks; 107 | const barStatsMax = pendingStatsCalculation + completedStatsCalculation; 108 | 109 | let barLenght = Math.ceil(BAR_WIDTH * modifier); 110 | 111 | let searchBarLenght = proportional( 112 | completedSearchTasks, 113 | BAR_WIDTH, 114 | barSearchMax, 115 | ); 116 | searchBarLenght = Math.ceil(searchBarLenght * modifier); 117 | 118 | let doneBarLenght = proportional( 119 | completedStatsCalculation, 120 | searchBarLenght, 121 | barStatsMax, 122 | ); 123 | doneBarLenght = Math.floor(doneBarLenght * modifier); 124 | 125 | barLenght -= searchBarLenght; 126 | searchBarLenght -= doneBarLenght; 127 | 128 | // Debug 129 | // this.printAt( 130 | // `V: ${barSearchMax},T: ${barLenght},C: ${searchBarLenght},D:${doneBarLenght} `, 131 | // { x: 60, y: 5 }, 132 | // ); 133 | 134 | const progressBar = 135 | BAR_PARTS.completed.repeat(doneBarLenght) + 136 | BAR_PARTS.searchTask.repeat(searchBarLenght) + 137 | BAR_PARTS.bg.repeat(barLenght); 138 | 139 | this.printProgressBar(progressBar); 140 | } 141 | 142 | private animateProgressBar(): void { 143 | if (this.barNormalizedWidth > 1) { 144 | this.barNormalizedWidth = 1; 145 | return; 146 | } 147 | this.barNormalizedWidth += 0.05; 148 | 149 | this.renderProgressBar(); 150 | setTimeout(() => this.animateProgressBar(), SPINNER_INTERVAL); 151 | } 152 | 153 | private animateClose(): void { 154 | this.barClosing = true; 155 | if (this.barNormalizedWidth < 0) { 156 | this.barNormalizedWidth = 0; 157 | this.showProgressBar = false; 158 | 159 | this.movePendingTaskToTop(); 160 | return; 161 | } 162 | this.barNormalizedWidth -= 0.05; 163 | 164 | this.renderProgressBar(); 165 | setTimeout(() => this.animateClose(), SPINNER_INTERVAL); 166 | } 167 | 168 | /** When the progress bar disappears, "pending tasks" will move up one 169 | position. */ 170 | private movePendingTaskToTop(): void { 171 | this.clearPendingTasks(); 172 | this.pendingTasksPosition = { ...UI_POSITIONS.STATUS_BAR }; 173 | this.renderPendingTasks(); 174 | } 175 | 176 | private printProgressBar(progressBar: string): void { 177 | if (this.barClosing) { 178 | const postX = 179 | UI_POSITIONS.STATUS_BAR.x - 180 | 1 + 181 | Math.round((BAR_WIDTH / 2) * (1 - this.barNormalizedWidth)); 182 | // Clear previus bar 183 | this.printAt(' '.repeat(BAR_WIDTH), UI_POSITIONS.STATUS_BAR); 184 | 185 | this.printAt(progressBar, { 186 | x: postX, 187 | y: UI_POSITIONS.STATUS_BAR.y, 188 | }); 189 | } else { 190 | this.printAt(progressBar, UI_POSITIONS.STATUS_BAR); 191 | } 192 | } 193 | 194 | private startingSearch(): void { 195 | this.text = INFO_MSGS.STARTING; 196 | this.render(); 197 | } 198 | 199 | private continueSearching(): void { 200 | this.text = INFO_MSGS.SEARCHING + this.spinnerService.nextFrame(); 201 | this.render(); 202 | } 203 | 204 | private fatalError(): void { 205 | this.text = colors.red(INFO_MSGS.FATAL_ERROR); 206 | this.searchEnd$.next(true); 207 | this.searchEnd$.complete(); 208 | this.render(); 209 | } 210 | 211 | private continueFinishing(): void { 212 | this.text = INFO_MSGS.CALCULATING_STATS + this.spinnerService.nextFrame(); 213 | this.render(); 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /src/ui/components/help.ui.ts: -------------------------------------------------------------------------------- 1 | import ansiEscapes from 'ansi-escapes'; 2 | import { 3 | HELP_HEADER, 4 | OPTIONS, 5 | HELP_FOOTER, 6 | HELP_PROGRESSBAR, 7 | } from '../../constants/cli.constants.js'; 8 | import { MARGINS, UI_HELP } from '../../constants/main.constants.js'; 9 | import { INFO_MSGS } from '../../constants/messages.constants.js'; 10 | import { IPosition } from '../../interfaces/ui-positions.interface.js'; 11 | import { ConsoleService } from '../../services/console.service.js'; 12 | import { BaseUi } from '../base.ui.js'; 13 | import colors from 'colors'; 14 | 15 | export class HelpUi extends BaseUi { 16 | constructor(private readonly consoleService: ConsoleService) { 17 | super(); 18 | } 19 | 20 | render(): void { 21 | throw new Error('Method not implemented.'); 22 | } 23 | 24 | show(): void { 25 | this.clear(); 26 | this.print(colors.inverse(INFO_MSGS.HELP_TITLE + '\n\n')); 27 | this.print(HELP_HEADER + '\n\n'); 28 | this.print(HELP_PROGRESSBAR + '\n\n'); 29 | 30 | let lineCount = 0; 31 | OPTIONS.forEach((option, index) => { 32 | this.printAtHelp( 33 | option.arg.reduce((text, arg) => text + ', ' + arg), 34 | { 35 | x: UI_HELP.X_COMMAND_OFFSET, 36 | y: index + UI_HELP.Y_OFFSET + lineCount, 37 | }, 38 | ); 39 | const description = this.consoleService.splitWordsByWidth( 40 | option.description, 41 | this.terminal.columns - UI_HELP.X_DESCRIPTION_OFFSET, 42 | ); 43 | 44 | description.forEach((line) => { 45 | this.printAtHelp(line, { 46 | x: UI_HELP.X_DESCRIPTION_OFFSET, 47 | y: index + UI_HELP.Y_OFFSET + lineCount, 48 | }); 49 | ++lineCount; 50 | }); 51 | }); 52 | 53 | this.print(HELP_FOOTER + '\n'); 54 | } 55 | 56 | clear(): void { 57 | for (let row = MARGINS.ROW_RESULTS_START; row < this.terminal.rows; row++) { 58 | this.clearLine(row); 59 | } 60 | } 61 | 62 | private printAtHelp(message: string, position: IPosition): void { 63 | this.setCursorAtHelp(position); 64 | this.print(message); 65 | if (!/-[a-zA-Z]/.test(message.substring(0, 2)) && message !== '') { 66 | this.print('\n\n'); 67 | } 68 | } 69 | 70 | private setCursorAtHelp({ x }: IPosition): void { 71 | this.print(ansiEscapes.cursorTo(x)); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ui/components/logs.ui.ts: -------------------------------------------------------------------------------- 1 | import { LoggerService } from '../../services/logger.service.js'; 2 | import { InteractiveUi, BaseUi } from '../base.ui.js'; 3 | import colors from 'colors'; 4 | import { IPosition } from '../../interfaces/ui-positions.interface.js'; 5 | import { Subject } from 'rxjs'; 6 | import { IKeyPress } from '../../interfaces/key-press.interface.js'; 7 | 8 | export class LogsUi extends BaseUi implements InteractiveUi { 9 | readonly close$ = new Subject(); 10 | private size: IPosition; 11 | private errors = 0; 12 | private pages: string[][] = []; 13 | private actualPage = 0; 14 | 15 | private readonly KEYS = { 16 | e: () => this.cyclePages(), 17 | escape: () => this.close(), 18 | }; 19 | 20 | constructor(private readonly logger: LoggerService) { 21 | super(); 22 | this.setVisible(false, false); 23 | } 24 | 25 | onKeyInput({ name }: IKeyPress): void { 26 | const action = this.KEYS[name]; 27 | if (action === undefined) { 28 | return; 29 | } 30 | action(); 31 | } 32 | 33 | render(): void { 34 | this.renderPopup(); 35 | } 36 | 37 | private cyclePages(): void { 38 | this.actualPage++; 39 | if (this.actualPage >= this.pages.length) { 40 | this.actualPage = 0; 41 | this.close(); 42 | return; 43 | } 44 | 45 | this.render(); 46 | } 47 | 48 | private close(): void { 49 | this.close$.next(null); 50 | } 51 | 52 | private renderPopup(): void { 53 | this.calculatePosition(); 54 | for (let x = this.position.x; x < this.size.x; x++) { 55 | for (let y = this.position.y; y < this.size.y; y++) { 56 | let char = ' '; 57 | if (x === this.position.x || x === this.size.x - 1) { 58 | char = '│'; 59 | } 60 | if (y === this.position.y) { 61 | char = '═'; 62 | } 63 | if (y === this.size.y - 1) { 64 | char = '─'; 65 | } 66 | if (x === this.position.x && y === this.position.y) { 67 | char = '╒'; 68 | } 69 | if (x === this.size.x - 1 && y === this.position.y) { 70 | char = '╕'; 71 | } 72 | if (x === this.position.x && y === this.size.y - 1) { 73 | char = '╰'; 74 | } 75 | if (x === this.size.x - 1 && y === this.size.y - 1) { 76 | char = '╯'; 77 | } 78 | 79 | this.printAt(colors['bgBlack'](char), { x, y }); 80 | } 81 | } 82 | 83 | const width = this.size.x - this.position.x - 2; 84 | const maxEntries = this.size.y - this.position.y - 2; 85 | 86 | const messagesByLine: string[] = this.logger 87 | .get('error') 88 | .map((entry, index) => `${index}. ${entry.message}`) 89 | .reduce((acc: string[], line) => { 90 | acc = [...acc, ...this.chunkString(line, width)]; 91 | return acc; 92 | }, []); 93 | 94 | this.pages = this.chunkArray(messagesByLine, maxEntries); 95 | this.errors = this.logger.get('error').length; 96 | 97 | if (messagesByLine.length === 0) { 98 | this.printAt(this.stylizeText('No errors!'), { 99 | x: this.position.x + 1, 100 | y: this.position.y + 1, 101 | }); 102 | } 103 | 104 | this.pages[this.actualPage].forEach((entry, index) => { 105 | this.printAt(this.stylizeText(entry, 'error'), { 106 | x: this.position.x + 1, 107 | y: this.position.y + 1 + index, 108 | }); 109 | }); 110 | 111 | this.printHeader(); 112 | } 113 | 114 | private printHeader(): void { 115 | const titleText = ' Errors '; 116 | this.printAt(this.stylizeText(titleText), { 117 | x: Math.floor((this.size.x + titleText.length / 2) / 2) - this.position.x, 118 | y: this.position.y, 119 | }); 120 | 121 | const rightText = ` ${this.errors} errors | Page ${this.actualPage + 1}/${ 122 | this.pages.length 123 | } `; 124 | 125 | this.printAt(this.stylizeText(rightText), { 126 | x: Math.floor(this.size.x + this.position.x - 4 - (rightText.length + 2)), 127 | y: this.position.y, 128 | }); 129 | } 130 | 131 | private stylizeText( 132 | text: string, 133 | style: 'normal' | 'error' = 'normal', 134 | ): string { 135 | const styles = { normal: 'white', error: 'red' }; 136 | const color = styles[style]; 137 | return colors[color](colors['bgBlack'](text)); 138 | } 139 | 140 | private chunkString(str: string, length: number): string[] { 141 | const matches = str.match(new RegExp(`.{1,${length}}`, 'g')); 142 | return matches !== null ? [...matches] : []; 143 | } 144 | 145 | private chunkArray(arr: string[], size: number): string[][] { 146 | return arr.length > size 147 | ? [arr.slice(0, size), ...this.chunkArray(arr.slice(size), size)] 148 | : [arr]; 149 | } 150 | 151 | private calculatePosition(): void { 152 | const posX = 5; 153 | const posY = 4; 154 | this.setPosition({ x: posX, y: posY }, false); 155 | this.size = { 156 | x: this.terminal.columns - posX, 157 | y: this.terminal.rows - 3, 158 | }; 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/ui/components/warning.ui.ts: -------------------------------------------------------------------------------- 1 | import { InteractiveUi, BaseUi } from '../base.ui.js'; 2 | import { Subject } from 'rxjs'; 3 | import { IKeyPress } from '../../interfaces/key-press.interface.js'; 4 | import { INFO_MSGS, UI_POSITIONS } from '../../constants/index.js'; 5 | 6 | export class WarningUi extends BaseUi implements InteractiveUi { 7 | private showDeleteAllWarning = false; 8 | readonly confirm$ = new Subject(); 9 | 10 | private readonly KEYS = { 11 | y: () => this.confirm$.next(null), 12 | }; 13 | 14 | onKeyInput({ name }: IKeyPress): void { 15 | const action = this.KEYS[name]; 16 | if (action === undefined) { 17 | return; 18 | } 19 | action(); 20 | } 21 | 22 | setDeleteAllWarningVisibility(visible: boolean): void { 23 | this.showDeleteAllWarning = visible; 24 | this.render(); 25 | } 26 | 27 | render(): void { 28 | if (this.showDeleteAllWarning) { 29 | this.printDeleteAllWarning(); 30 | } 31 | } 32 | 33 | private printDeleteAllWarning(): void { 34 | this.printAt(INFO_MSGS.DELETE_ALL_WARNING, UI_POSITIONS.WARNINGS); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ui/heavy.ui.ts: -------------------------------------------------------------------------------- 1 | import { BaseUi } from './base.ui.js'; 2 | 3 | /** 4 | * A UI that buffers the output and prints it all at once when calling the 5 | * flush() function. 6 | */ 7 | export abstract class HeavyUi extends BaseUi { 8 | private buffer = ''; 9 | private previousBuffer = ''; 10 | 11 | /** 12 | * Stores the text in a buffer. No will print it to stdout until flush() 13 | * is called. 14 | */ 15 | protected override print(text: string): void { 16 | this.buffer += text; 17 | } 18 | 19 | /** Prints the buffer (if have any change) to stdout and clears it. */ 20 | protected flush(): void { 21 | if (this.freezed) { 22 | return; 23 | } 24 | 25 | if (this.buffer === this.previousBuffer) { 26 | this.clearBuffer(); 27 | return; 28 | } 29 | 30 | process.stdout.write.bind(process.stdout)(this.buffer); 31 | this.clearBuffer(); 32 | } 33 | 34 | private clearBuffer(): void { 35 | this.previousBuffer = this.buffer; 36 | this.buffer = ''; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/ui/index.ts: -------------------------------------------------------------------------------- 1 | export * from './base.ui.js'; 2 | export * from './heavy.ui.js'; 3 | export * from './components/general.ui.js'; 4 | export * from './components/help.ui.js'; 5 | export * from './components/logs.ui.js'; 6 | export * from './components/warning.ui.js'; 7 | export * from './components/results.ui.js'; 8 | export * from './components/header/header.ui.js'; 9 | export * from './components/header/stats.ui.js'; 10 | export * from './components/header/status.ui.js'; 11 | -------------------------------------------------------------------------------- /stryker.conf.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@stryker-mutator/api/core').StrykerOptions} 3 | */ 4 | const config = { 5 | packageManager: 'npm', 6 | reporters: ['html', 'clear-text', 'progress'], 7 | // testRunner: 'jest', // Using npm test by default 8 | testRunnerNodeArgs: ['--experimental-vm-modules', '--experimental-modules'], 9 | coverageAnalysis: 'perTest', 10 | jest: { 11 | projectType: 'custom', 12 | configFile: './jest.config.ts', 13 | enableFindRelatedTests: true, 14 | }, 15 | }; 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "sourceMap": true, 5 | "module": "ESNext", 6 | "target": "ESNext", 7 | "moduleResolution": "node", 8 | "declaration": false, 9 | "rootDir": "./src/", 10 | "outDir": "./lib/", 11 | "strict": true, 12 | "strictFunctionTypes": true, 13 | "noImplicitAny": false, 14 | "noImplicitOverride": true, 15 | "strictNullChecks": true, 16 | "strictPropertyInitialization": false, 17 | "resolveJsonModule": true, 18 | "allowSyntheticDefaultImports": true, 19 | "baseUrl": "." 20 | }, 21 | "include": ["src"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": {}, 5 | "rules": { 6 | "quotemark": [true, "single"], 7 | "member-access": [true, "no-public"], 8 | "arrow-parens": false, 9 | "no-shadowed-variable": false, 10 | "no-string-literal": false, 11 | "curly": false, 12 | "ordered-imports": false 13 | }, 14 | "rulesDirectory": [] 15 | } 16 | --------------------------------------------------------------------------------