├── .editorconfig ├── .eslintrc ├── .github └── workflows │ ├── build.yml │ ├── linux.yml │ ├── macos.yml │ ├── publish-prerelease.yml │ ├── publish-release.yml │ └── windows.yml ├── .gitignore ├── .prettierrc ├── .proxyrc.json ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── INFO ├── LICENSE ├── README.md ├── client ├── .eslintrc ├── Styled.ts ├── app │ ├── ContextStore.tsx │ ├── projects.storage.ts │ └── store.reducer.ts ├── base.css ├── components │ ├── App.tsx │ ├── Header │ │ ├── Header.tsx │ │ ├── components │ │ │ ├── Explorer.tsx │ │ │ ├── ExplorerUi.ts │ │ │ └── use-explorer.ts │ │ └── use-header.ts │ ├── Info.tsx │ └── Project │ │ ├── Dependencies │ │ ├── Dependencies.tsx │ │ ├── DependenciesHeader │ │ │ ├── DependenciesHeader.tsx │ │ │ └── Search │ │ │ │ ├── Search.tsx │ │ │ │ ├── SearchForm.tsx │ │ │ │ ├── SearchInstall.tsx │ │ │ │ └── use-search.ts │ │ ├── InstallHeader.tsx │ │ ├── ToInstallHeader.tsx │ │ └── table-cells │ │ │ ├── ActionsCell │ │ │ ├── ActionsCell.tsx │ │ │ └── TableActions.tsx │ │ │ ├── CompatibleCell.tsx │ │ │ ├── HomePageCell.tsx │ │ │ ├── InstallCell │ │ │ ├── Install.tsx │ │ │ └── InstallCell.tsx │ │ │ ├── InstalledCell.tsx │ │ │ ├── LatestCell.tsx │ │ │ ├── NameCell.tsx │ │ │ ├── NpmCell.tsx │ │ │ ├── OtherVersionCell │ │ │ ├── FindOtherVersions │ │ │ │ ├── FindOtherVersion.tsx │ │ │ │ ├── VersionColumn.tsx │ │ │ │ └── use-find-other-version.ts │ │ │ └── OtherVersionCell.tsx │ │ │ ├── RepoCell.tsx │ │ │ ├── ScoreCell.tsx │ │ │ ├── SizeCell.tsx │ │ │ ├── TimeCell.tsx │ │ │ ├── TypeCell.tsx │ │ │ └── VersionCells │ │ │ └── TableVersion.tsx │ │ ├── Project.tsx │ │ ├── ProjectJobs │ │ ├── JobItem.tsx │ │ └── ProjectJobs.tsx │ │ └── use-project.ts ├── hooks │ ├── use-available-managers.ts │ ├── use-bundle-details.ts │ ├── use-bundle-score.ts │ ├── use-fast-dependencies.ts │ ├── use-full-dependencies.ts │ ├── use-mutate-dependencies.ts │ ├── use-mutate-install-dependency.ts │ ├── use-mutate-reinstall.ts │ └── use-project-path.ts ├── index.html ├── index.tsx ├── service │ ├── dependencies-cache.ts │ ├── dependencies.service.ts │ └── utils.ts ├── tsconfig.json ├── ui │ ├── Button │ │ ├── Button.tsx │ │ └── Link.tsx │ ├── Dropdown │ │ └── Drodpown.tsx │ ├── Icon │ │ └── Icon.tsx │ ├── Loader.tsx │ ├── Modal │ │ └── Modal.tsx │ ├── ScoreBadge │ │ └── ScoreBadge.tsx │ ├── Table │ │ ├── Table.tsx │ │ ├── components │ │ │ ├── SelectFilter.tsx │ │ │ ├── TbodyRow.tsx │ │ │ ├── TextFilter.tsx │ │ │ ├── Th.tsx │ │ │ └── shared.tsx │ │ ├── use-table-filter.ts │ │ └── use-table-sort.ts │ └── hooks │ │ ├── use-click-outside.ts │ │ ├── use-interval.ts │ │ └── use-toggle.ts ├── utils.ts └── xcache.ts ├── index.js ├── install-correct-pnpm-version.js ├── jest.config.js ├── package-lock.json ├── package.json ├── server ├── .eslintrc ├── actions │ ├── available-managers │ │ └── available-managers.ts │ ├── dependencies │ │ ├── add │ │ │ ├── add-global-dependencies.ts │ │ │ └── add-project-dependencies.ts │ │ ├── delete │ │ │ ├── delete-global-dependencies.ts │ │ │ └── delete-project-dependencies.ts │ │ ├── extras │ │ │ ├── dependency-details.ts │ │ │ ├── dependency-score.ts │ │ │ └── utils.ts │ │ ├── get │ │ │ ├── get-global-dependencies.ts │ │ │ └── get-project-dependencies.ts │ │ └── install │ │ │ └── install-project-dependencies.ts │ ├── execute-command.ts │ ├── explorer │ │ └── explorer.ts │ ├── info │ │ └── info.ts │ ├── pnpm-utils.ts │ ├── search │ │ └── search.ts │ └── yarn-utils.ts ├── development.ts ├── index.html ├── index.ts ├── middlewares │ └── project-path-and-manager.middleware.ts ├── simple-express.ts ├── tsconfig.json ├── types │ ├── commands.types.ts │ ├── dependency.types.ts │ ├── global.types.ts │ ├── new-server.types.ts │ ├── pnpm.types.ts │ └── yarn.types.ts └── utils │ ├── cache.ts │ ├── delete-folder-resursive.ts │ ├── get-project-package-json.ts │ ├── map-dependencies.ts │ ├── parse-json.ts │ ├── request-with-promise.ts │ ├── simple-cross-spawn.ts │ └── utils.ts ├── tests ├── add-multiple.test.ts ├── add-single.test.ts ├── cache.test.ts ├── comparators.test.ts ├── delete.test.ts ├── explorer.test.ts ├── extras.test.ts ├── fetching.test.ts ├── global.d.ts ├── global.test.ts ├── info.test.ts ├── install-force.test.ts ├── install.test.ts ├── invalid-project.test.ts ├── managers.test.ts ├── search.test.ts ├── setup-tests.js ├── test-package.json ├── tests-utils.ts ├── tsconfig.json └── units.test.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "turbocharge" 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration Build Test 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version: '16' 16 | - run: node -v 17 | - run: npm -v 18 | 19 | - run: npm ci 20 | - run: npm run build 21 | -------------------------------------------------------------------------------- /.github/workflows/linux.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration Linux 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node-version: [ 15 | # 10.13, 16 | # 10.x, 17 | 18 | 11.0.0, 19 | 11.x, 20 | 21 | 12.0.0, 22 | 12.12.x, 23 | 12.x, 24 | 25 | 14.1.x, 26 | 14.14.x, 27 | 14.x, 28 | 29 | 15.0.0, 30 | 15.x, 31 | 32 | 16.0.0, 33 | 16.x, 34 | 35 | 17.0.0, 36 | 17.x, 37 | 38 | 18.0.0, 39 | 18.x, 40 | ] 41 | 42 | steps: 43 | - uses: actions/checkout@v3 44 | - name: Use Node.js ${{ matrix.node-version }} 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | - run: node -v 49 | - run: npm -v 50 | 51 | - run: node install-correct-pnpm-version.js 52 | 53 | - run: yarn -v 54 | - run: pnpm -v 55 | 56 | # TODO 57 | # we need to remove global test 58 | - run: rm tests/global.test.ts 59 | 60 | # we need to remove parcel before install dependencies 61 | - run: rm package-lock.json 62 | - run: npm uninstall parcel 63 | 64 | # install dependencies without parcel 65 | - run: npm install 66 | - run: npm run ci:test 67 | -------------------------------------------------------------------------------- /.github/workflows/macos.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration MacOS 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | build: 9 | runs-on: macos-latest 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node-version: [ 15 | # 10.13, 16 | # 10.x, 17 | 18 | 11.0.0, 19 | 11.x, 20 | 21 | 12.0.0, 22 | 12.12.x, 23 | 12.x, 24 | 25 | 14.1.x, 26 | 14.14.x, 27 | 14.x, 28 | 29 | 15.0.0, 30 | 15.x, 31 | 32 | 16.0.0, 33 | 16.x, 34 | 35 | 17.0.0, 36 | 17.x, 37 | 38 | 18.0.0, 39 | 18.x, 40 | ] 41 | 42 | steps: 43 | - uses: actions/checkout@v3 44 | - name: Use Node.js ${{ matrix.node-version }} 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | - run: node -v 49 | - run: npm -v 50 | 51 | - run: node install-correct-pnpm-version.js 52 | 53 | - run: yarn -v 54 | - run: pnpm -v 55 | 56 | # TODO 57 | # we need to remove global test 58 | - run: rm tests/global.test.ts 59 | 60 | # we need to remove parcel before install dependencies 61 | - run: rm package-lock.json 62 | - run: npm uninstall parcel 63 | 64 | # install dependencies without parcel 65 | - run: npm install 66 | - run: npm run ci:test 67 | -------------------------------------------------------------------------------- /.github/workflows/publish-prerelease.yml: -------------------------------------------------------------------------------- 1 | name: Release beta package 2 | 3 | on: 4 | release: 5 | types: [prereleased] 6 | 7 | jobs: 8 | build: 9 | # Run on latest version of ubuntu 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | # "ref" specifies the branch to check out. 16 | # "github.event.release.target_commitish" is a global variable and specifies the branch the release targeted 17 | ref: ${{ github.event.release.target_commitish }} 18 | # install Node.js 19 | - name: Use Node.js 18 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | # Specifies the registry, this field is required! 24 | registry-url: https://registry.npmjs.org/ 25 | # clean install of your projects' deps. We use "npm ci" to avoid package lock changes 26 | - run: npm ci 27 | # set up git since we will later push to the repo 28 | - run: git config --global user.name "GitHub CD bot" 29 | - run: git config --global user.email "pawel@devnullapps.com" 30 | # upgrade npm version in package.json to the tag used in the release. 31 | - run: npm version ${{ github.event.release.tag_name }} 32 | # build the project 33 | - run: npm run build 34 | # publish to NPM -> there is one caveat, continue reading for the fix 35 | - run: npm publish --tag beta 36 | env: 37 | # Use a token to publish to NPM. See below for how to set it up 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Release latest package 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | jobs: 8 | build: 9 | # Run on latest version of ubuntu 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | # "ref" specifies the branch to check out. 16 | # "github.event.release.target_commitish" is a global variable and specifies the branch the release targeted 17 | ref: ${{ github.event.release.target_commitish }} 18 | # install Node.js 19 | - name: Use Node.js 18 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 18 23 | # Specifies the registry, this field is required! 24 | registry-url: https://registry.npmjs.org/ 25 | # clean install of your projects' deps. We use "npm ci" to avoid package lock changes 26 | - run: npm ci 27 | # set up git since we will later push to the repo 28 | - run: git config --global user.name "GitHub CD bot" 29 | - run: git config --global user.email "pawel@devnullapps.com" 30 | # upgrade npm version in package.json to the tag used in the release. 31 | - run: npm version ${{ github.event.release.tag_name }} 32 | # build the project 33 | - run: npm run build 34 | # publish to NPM -> there is one caveat, continue reading for the fix 35 | - run: npm publish --tag latest 36 | env: 37 | # Use a token to publish to NPM. See below for how to set it up 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /.github/workflows/windows.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration Windows 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | 7 | jobs: 8 | build: 9 | runs-on: windows-latest 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node-version: [ 15 | # 10.13, 16 | # 10.x, 17 | 18 | 11.0.0, 19 | 11.x, 20 | 21 | 12.0.0, 22 | 12.12.x, 23 | 12.x, 24 | 25 | 14.1.x, 26 | 14.14.x, 27 | 14.x, 28 | 29 | 15.0.0, 30 | 15.x, 31 | 32 | 16.0.0, 33 | 16.x, 34 | 35 | 17.0.0, 36 | # 17.x, 37 | 38 | 18.0.0, 39 | 18.x, 40 | ] 41 | 42 | steps: 43 | - uses: actions/checkout@v3 44 | - name: Use Node.js ${{ matrix.node-version }} 45 | uses: actions/setup-node@v3 46 | with: 47 | node-version: ${{ matrix.node-version }} 48 | - run: node -v 49 | - run: npm -v 50 | 51 | - run: node install-correct-pnpm-version.js 52 | 53 | - run: yarn -v 54 | - run: pnpm -v 55 | 56 | # TODO 57 | # we need to remove global test 58 | - run: rm tests/global.test.ts 59 | 60 | # we need to remove parcel before install dependencies 61 | - run: rm package-lock.json 62 | - run: npm uninstall parcel 63 | 64 | # install dependencies without parcel 65 | - run: npm install 66 | - run: npm run ci:test 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | bower_components 4 | npm-debug* 5 | dist 6 | npm-gui* 7 | electron 8 | report.* 9 | .cache 10 | tests/test-projec* 11 | tsconfig.tsbuildinfo 12 | .cache 13 | eslint-config* 14 | .nyc_output 15 | .parcel-cache 16 | coverage 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "eslint-config-turbocharge/prettier" 2 | -------------------------------------------------------------------------------- /.proxyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api/": { 3 | "target": "http://localhost:3000" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "orta.vscode-jest", 5 | "esbenp.prettier-vscode", 6 | "styled-components.vscode-styled-components" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll": true 7 | }, 8 | "jest.autoRun": { "watch": false, "onSave": "test-file" } 9 | } 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | ## How to contribute 3 | 4 | #### **Did you find a bug?** 5 | 6 | * **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/q-nick/npm-gui/issues). 7 | 8 | * If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/q-nick/npm-gui/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. 9 | 10 | #### **Did you write a patch that fixes a bug?** 11 | 12 | * Open a new GitHub pull request with the patch. 13 | 14 | * Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. 15 | 16 | #### **Do you intend to add a new feature or change an existing one?** 17 | 18 | * Suggest your change 19 | 20 | * Do not open an issue on GitHub until you have collected positive feedback about the change. GitHub issues are primarily intended for bug reports and fixes. 21 | 22 | #### **Do you want to contribute?** 23 | 24 | Thanks! :heart: :heart: 25 | -------------------------------------------------------------------------------- /INFO: -------------------------------------------------------------------------------- 1 |
2 |

The new version of npm-gui is out now! Start it with:

3 | 4 |
npx npm-gui@latest
5 | 6 | Check it out! 7 | 8 | Go to Documentation 9 | 10 |
11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Paweł Stefański 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Downloads](https://img.shields.io/npm/dm/npm-gui?style=for-the-badge)](https://www.npmjs.com/package/npm-gui) 2 |   3 | [![MIT License](https://img.shields.io/npm/l/npm-gui?style=for-the-badge)](https://choosealicense.com/licenses/mit/) 4 |   5 | [![Github](https://img.shields.io/github/stars/q-nick/npm-gui?style=for-the-badge)](https://github.com/q-nick/npm-gui) 6 |   7 | [![npm](https://img.shields.io/npm/v/npm-gui?style=for-the-badge)](https://www.npmjs.com/package/npm-gui) 8 | 9 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/q-nick/npm-gui/build.yml?style=for-the-badge) 10 |   11 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/q-nick/npm-gui/windows.yml?label=windows%20test&style=for-the-badge) 12 |   13 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/q-nick/npm-gui/macos.yml?label=macos%20test&style=for-the-badge) 14 |   15 | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/q-nick/npm-gui/linux.yml?label=linux%20test&style=for-the-badge) 16 | 17 | # npm-gui 18 | 19 | Homepage and full documentation: https://npm-gui.nullapps.dev 20 | 21 | `npm-gui` is a convenient tool for managing javascript project dependencies listed in `package.json`. Under the hood, it will transparently use `npm`, `pnpm`, or `yarn` commands to install, remove or update dependencies 22 | (_to use **yarn** it requires the **yarn.lock** file to be present in the project folder._) 23 | 24 | ![App Demo](https://npm-gui.nullapps.dev/batch-install.GIF) 25 | 26 | ## Getting Started 27 | 28 | The recommended way to run `npm-gui` is by using `npx`: 29 | 30 | ``` 31 | ~/$ npx npm-gui@latest 32 | ``` 33 | 34 | It will run the most recent version of `npm-gui` without installing it on your system. 35 | 36 | #### Installation as global dependency 37 | 38 | `npm-gui` could also be installed as a global dependency: 39 | 40 | ``` 41 | ~/$ npm install -g npm-gui 42 | ``` 43 | 44 | and then run with just: 45 | 46 | ``` 47 | ~/$ npm-gui 48 | ``` 49 | 50 | #### Installation as local dependency (not-recommended) 51 | 52 | ``` 53 | ~/$ npm install npm-gui 54 | ``` 55 | 56 | To read more visit: https://npm-gui.nullapps.dev/docs/npm-gui/ 57 | 58 | ## Authors 59 | 60 | - [@q-nick](https://www.github.com/q-nick) 61 | 62 | ## Documentation 63 | 64 | [Documentation](https://npm-gui.nullapps.dev/docs/npm-gui/) 65 | 66 | ## Next features on roadmap 67 | 68 | - npm-gui integrated into VS Code as an extension 69 | 70 | - other package managers like: _poetry_, _composer_, _nuget_ 71 | 72 | - packages updates history 73 | 74 | - re-arrange existing columns 75 | 76 | - expandable/collapsable module to reveal it's dependency tree _(npm-remote-ls)_ 77 | 78 | - number of dependencies per module 79 | 80 | - move dependency between dev and prod 81 | 82 | - visual indicator if the package seems to be unuse _(depcheck)_ 83 | 84 | - hint like: "shouldn't this be a dev-dependency?" 85 | -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "turbocharge/react" 4 | } 5 | -------------------------------------------------------------------------------- /client/Styled.ts: -------------------------------------------------------------------------------- 1 | import type { Interpolation } from 'styled-components'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-type-alias 4 | export type CSSType = Interpolation; 5 | -------------------------------------------------------------------------------- /client/app/ContextStore.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react'; 2 | import { 3 | createContext, 4 | useCallback, 5 | useContext, 6 | useMemo, 7 | useReducer, 8 | } from 'react'; 9 | 10 | import type { Action, State } from './store.reducer'; 11 | import { initialState, storeReducer } from './store.reducer'; 12 | 13 | interface Context { 14 | state: State; 15 | dispatch: (action: Action) => void; 16 | } 17 | 18 | export const ContextStore = createContext({ 19 | state: initialState, 20 | // eslint-disable-next-line @typescript-eslint/no-empty-function 21 | dispatch() {}, 22 | }); 23 | 24 | export const ContextStoreProvider: FC<{ children: ReactNode }> = ({ 25 | children, 26 | }) => { 27 | const [state, dispatch] = useReducer(storeReducer, { 28 | ...initialState, 29 | }); 30 | 31 | const contextValue = useMemo(() => { 32 | return { state, dispatch }; 33 | }, [state, dispatch]); 34 | 35 | return ( 36 | 37 | {children} 38 | 39 | ); 40 | }; 41 | 42 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 43 | export const useProjectsStore = () => { 44 | const { 45 | state: { projects }, 46 | dispatch, 47 | } = useContext(ContextStore); 48 | 49 | return { 50 | projects, 51 | dispatch, 52 | }; 53 | }; 54 | 55 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 56 | export const useProjectStore = (projectPath: string) => { 57 | const { projects, dispatch } = useProjectsStore(); 58 | 59 | return { 60 | project: projects.find((project) => project.path === projectPath), 61 | dispatch, 62 | }; 63 | }; 64 | 65 | let id = 0; 66 | 67 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 68 | export const useProjectsJobs = (projectPath: string) => { 69 | const { dispatch } = useProjectStore(projectPath); 70 | 71 | const startJob = useCallback( 72 | (description) => { 73 | id += 1; 74 | 75 | dispatch({ 76 | action: 'jobStarted', 77 | projectPath, 78 | description, 79 | jobId: id, 80 | }); 81 | 82 | return id; 83 | }, 84 | [dispatch, projectPath], 85 | ); 86 | 87 | const successJob = useCallback( 88 | (jobId: number) => { 89 | dispatch({ 90 | action: 'jobSuccess', 91 | projectPath, 92 | jobId, 93 | }); 94 | }, 95 | [dispatch, projectPath], 96 | ); 97 | 98 | const failedJob = useCallback( 99 | (jobId: number) => { 100 | dispatch({ 101 | action: 'jobSuccess', 102 | projectPath, 103 | jobId, 104 | }); 105 | }, 106 | [dispatch, projectPath], 107 | ); 108 | 109 | const removeJob = useCallback( 110 | (jobId: number) => { 111 | dispatch({ 112 | action: 'jobRemove', 113 | jobId, 114 | projectPath, 115 | }); 116 | }, 117 | [dispatch, projectPath], 118 | ); 119 | 120 | return { 121 | startJob, 122 | successJob, 123 | failedJob, 124 | removeJob, 125 | }; 126 | }; 127 | -------------------------------------------------------------------------------- /client/app/projects.storage.ts: -------------------------------------------------------------------------------- 1 | const projectsFromStorage = localStorage.getItem('projects'); 2 | 3 | export const initialProjects = 4 | projectsFromStorage !== null 5 | ? Object.keys(JSON.parse(projectsFromStorage) as Record) 6 | : []; 7 | 8 | export const syncProjectsStorage = (projects: string[]): void => { 9 | localStorage.setItem( 10 | 'projects', 11 | JSON.stringify(Object.fromEntries(projects.map((current) => [current]))), 12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /client/base.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-size: 16px; 4 | font-weight: 500; 5 | margin: 0; 6 | } 7 | 8 | html, 9 | body { 10 | height: 100%; 11 | } 12 | 13 | * { 14 | box-sizing: border-box; 15 | } 16 | 17 | input, 18 | select { 19 | background: #fff; 20 | border: 1px solid #dfd7ca; 21 | border-radius: 2px; 22 | display: block; 23 | font-size: 11px; 24 | outline: none; 25 | padding: 3px; 26 | height: 24px; 27 | } 28 | 29 | table { 30 | border-collapse: collapse; 31 | color: #393a35; 32 | font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; 33 | font-size: 0.8em; 34 | width: 100%; 35 | } 36 | 37 | tr:hover { 38 | background: #f8f5f0; 39 | } 40 | 41 | th, 42 | td { 43 | border-bottom: 1px solid #dfd7ca; 44 | padding: 3px 0; 45 | text-align: center; 46 | max-width: 13vw; 47 | } 48 | 49 | th { 50 | white-space: nowrap; 51 | } 52 | 53 | th:last-child { 54 | border-right: 0; 55 | } 56 | 57 | .npm-gui { 58 | display: flex; 59 | flex-direction: column; 60 | max-height: 100vh; 61 | min-height: 100vh; 62 | overflow: hidden; 63 | } 64 | -------------------------------------------------------------------------------- /client/components/App.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | import { BrowserRouter as Router, Route } from 'react-router-dom'; 3 | 4 | import { ContextStoreProvider } from '../app/ContextStore'; 5 | import { Header } from './Header/Header'; 6 | import { Info } from './Info'; 7 | import { Project } from './Project/Project'; 8 | 9 | export const App: VFC = () => { 10 | return ( 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /client/components/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { Button } from '../../ui/Button/Button'; 6 | import { Explorer } from './components/Explorer'; 7 | import { useHeader } from './use-header'; 8 | 9 | export interface HeaderButton { 10 | text: string; 11 | routeName: string; 12 | icon: string; 13 | } 14 | 15 | const Nav = styled.nav` 16 | background: #3e3f3a; 17 | min-height: 35px; 18 | max-height: 35px; 19 | padding-left: 15px; 20 | padding-right: 15px; 21 | display: flex; 22 | justify-content: space-between; 23 | `; 24 | 25 | const RightSection = styled.div` 26 | display: flex; 27 | align-items: center; 28 | `; 29 | 30 | const LeftSection = styled.div` 31 | display: flex; 32 | align-items: center; 33 | `; 34 | 35 | const Title = styled.h1` 36 | color: #fff; 37 | font-size: 1em; 38 | font-weight: 400; 39 | margin: 0 15px 0 0; 40 | `; 41 | 42 | const CloseButton = styled(Button)` 43 | margin-right: 15px; 44 | margin-left: -3px; 45 | `; 46 | 47 | export const Header: VFC = () => { 48 | const { projectPathEncoded, projects, handleRemoveProject } = useHeader(); 49 | 50 | return ( 51 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /client/components/Header/components/Explorer.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | 3 | import { Button } from '../../../ui/Button/Button'; 4 | import { 5 | ExplorerButton, 6 | ExplorerCurrentLocation, 7 | ExplorerFile, 8 | ExplorerList, 9 | ExplorerSearch, 10 | Wrapper, 11 | } from './ExplorerUi'; 12 | import { useExplorer } from './use-explorer'; 13 | 14 | export const Explorer: VFC = () => { 15 | const { 16 | ref, 17 | onToggleIsOpen, 18 | isOpen, 19 | path, 20 | list, 21 | isFetching, 22 | filter, 23 | setFilter, 24 | onClickProject, 25 | setCurrentPath, 26 | } = useExplorer(); 27 | 28 | return ( 29 | 30 | 38 | 39 | 40 |
  • 41 | {path} 42 |
  • 43 |
  • 44 | setFilter(event.target.value)} 46 | placeholder="Type to filter" 47 | value={filter} 48 | /> 49 |
  • 50 |
  • 51 | { 55 | setCurrentPath(window.btoa(`${path}/../`)); 56 | }} 57 | > 58 | ../ 59 | 60 |
  • 61 | 62 | {list 63 | ?.filter((folderOrFile) => folderOrFile.name.includes(filter)) 64 | .map((folderOrFile) => ( 65 |
  • 66 | {folderOrFile.isDirectory && !folderOrFile.isProject && ( 67 | { 71 | setCurrentPath(window.btoa(`${path}/${folderOrFile.name}`)); 72 | }} 73 | > 74 | ├─   75 | 76 |   77 | {folderOrFile.name}/ 78 | 79 | )} 80 | 81 | {folderOrFile.isProject && ( 82 | 88 | ├─   89 | 90 |   91 | {folderOrFile.name} 92 | 93 | )} 94 | 95 | {!folderOrFile.isDirectory && !folderOrFile.isProject && ( 96 | 97 | ├─   98 | 99 |   100 | {folderOrFile.name} 101 | 102 | )} 103 |
  • 104 | ))} 105 |
    106 |
    107 | ); 108 | }; 109 | -------------------------------------------------------------------------------- /client/components/Header/components/ExplorerUi.ts: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import type { CSSType } from '../../../Styled'; 4 | 5 | export const Wrapper = styled.div` 6 | position: relative; 7 | z-index: 5; 8 | `; 9 | 10 | interface ExplorerListProps { 11 | isOpen: boolean; 12 | } 13 | 14 | export const ExplorerList = styled.ul` 15 | position: absolute; 16 | background: #3e3f3a; 17 | right: 0; 18 | top: 100%; 19 | z-index: 1; 20 | max-height: 0; 21 | max-width: 0; 22 | overflow: hidden; 23 | margin: 0; 24 | padding: 0; 25 | transition: max-width 300ms, max-height 300ms; 26 | width: 250px; 27 | 28 | ${({ isOpen }: ExplorerListProps): CSSType => 29 | isOpen && 30 | css` 31 | border-color: #dfd7ca; 32 | max-height: 80vh; 33 | max-width: 250px; 34 | overflow-y: scroll; 35 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5); 36 | `} 37 | `; 38 | 39 | interface ExplorerButtonProps { 40 | isDirectory: boolean; 41 | isProject?: boolean; 42 | } 43 | 44 | export const ExplorerButton = styled.button` 45 | color: #fff; 46 | background: none; 47 | font-size: 12px; 48 | font-weight: 500; 49 | border: 0; 50 | display: inline-block; 51 | width: 100%; 52 | text-align: left; 53 | padding: 0 8px; 54 | 55 | &:hover { 56 | background: #8e8c84; 57 | } 58 | 59 | ${({ isDirectory }: ExplorerButtonProps): CSSType => 60 | isDirectory && 61 | css` 62 | max-height: 80vh; 63 | max-width: 100%; 64 | `} 65 | 66 | ${({ isProject }: ExplorerButtonProps): CSSType => 67 | isProject === true && 68 | css` 69 | color: green; 70 | 71 | :hover { 72 | color: #000; 73 | } 74 | `} 75 | 76 | &:disabled { 77 | color: #8e8c84; 78 | background: none; 79 | text-decoration: none; 80 | cursor: not-allowed; 81 | } 82 | `; 83 | 84 | export const ExplorerFile = styled.span` 85 | color: #8e8c84; 86 | font-size: 12px; 87 | font-weight: 500; 88 | padding: 0 8px; 89 | white-space: nowrap; 90 | `; 91 | 92 | export const ExplorerCurrentLocation = styled.span` 93 | color: #8e8c84; 94 | font-size: 12px; 95 | font-weight: 500; 96 | padding: 0 3px; 97 | margin-top: 3px; 98 | white-space: nowrap; 99 | overflow: hidden; 100 | text-overflow: ellipsis; 101 | width: 100%; 102 | direction: rtl; 103 | display: block; 104 | `; 105 | 106 | export const ExplorerSearch = styled.input` 107 | display: block; 108 | background: black; 109 | border: 1px solid black; 110 | margin: 3px 3px 0 3px; 111 | width: calc(100% - 6px); 112 | color: white; 113 | padding: 0 5px; 114 | border-radius: 10px; 115 | `; 116 | -------------------------------------------------------------------------------- /client/components/Header/components/use-explorer.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { useCallback, useEffect, useState } from 'react'; 3 | import { useHistory } from 'react-router-dom'; 4 | 5 | import type { ExplorerResponse } from '../../../../server/types/global.types'; 6 | import { fetchJSON } from '../../../service/utils'; 7 | import { useClickOutsideRef } from '../../../ui/hooks/use-click-outside'; 8 | import { useToggle } from '../../../ui/hooks/use-toggle'; 9 | 10 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types 11 | export const useExplorer = () => { 12 | const { isOpen, onToggleIsOpen, onClose } = useToggle(); 13 | const [filter, setFilter] = useState(''); 14 | const [currentPath, setCurrentPath] = useState(''); 15 | const history = useHistory(); 16 | 17 | const ref = useClickOutsideRef(onClose); 18 | 19 | const { data, isFetching } = useQuery( 20 | [currentPath], 21 | () => fetchJSON(`/api/explorer/${currentPath}`), 22 | { 23 | refetchOnWindowFocus: false, 24 | refetchOnMount: false, 25 | retry: false, 26 | keepPreviousData: true, 27 | }, 28 | ); 29 | 30 | const onClickProject = useCallback((): void => { 31 | onClose(); 32 | if (data) { 33 | history.push(`/${window.btoa(data.path)}`); 34 | } 35 | }, [data, history, onClose]); 36 | 37 | useEffect(() => { 38 | if (!currentPath && data?.path) { 39 | setCurrentPath(data.path); 40 | history.push(`/${window.btoa(data.path)}`); 41 | } 42 | }, [currentPath, data?.path, history]); 43 | 44 | return { 45 | ref, 46 | onToggleIsOpen, 47 | isOpen, 48 | path: data?.path, 49 | list: data?.ls, 50 | filter, 51 | setFilter, 52 | onClickProject, 53 | setCurrentPath, 54 | isFetching, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /client/components/Header/use-header.ts: -------------------------------------------------------------------------------- 1 | import { useHistory } from 'react-router-dom'; 2 | 3 | import { useProjectsStore } from '../../app/ContextStore'; 4 | import { useProjectPath } from '../../hooks/use-project-path'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type, @typescript-eslint/explicit-module-boundary-types 7 | export const useHeader = () => { 8 | const projectPathEncoded = useProjectPath(); 9 | 10 | const { projects, dispatch } = useProjectsStore(); 11 | 12 | const history = useHistory(); 13 | 14 | const handleRemoveProject = (projectPathToRemove: string): void => { 15 | if (projectPathToRemove === projectPathEncoded) { 16 | history.push(`/`); 17 | } 18 | dispatch({ action: 'removeProject', projectPath: projectPathToRemove }); 19 | }; 20 | 21 | return { 22 | projectPathEncoded, 23 | handleRemoveProject, 24 | projects, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /client/components/Info.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | import { useEffect, useState } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | const InfoWrapper = styled.div` 6 | min-height: 45px; 7 | max-height: 45px; 8 | background: #3e3f3a; 9 | padding: 5px 15px; 10 | `; 11 | 12 | export const Info: VFC = () => { 13 | const [content, setContent] = useState(''); 14 | 15 | const load = async (): Promise => { 16 | const response = await fetch( 17 | `/api/info/${window.localStorage.getItem('npm-gui-id')}`, 18 | ); 19 | setContent(await response.text()); 20 | // Tricky one 21 | setTimeout(() => { 22 | const script = document.createElement('script'); 23 | script.src = 'https://buttons.github.io/buttons.js'; 24 | document.head.append(script); 25 | }); 26 | }; 27 | 28 | useEffect(() => { 29 | if (window.localStorage.getItem('npm-gui-id') !== 'developer') { 30 | load(); 31 | } 32 | }, []); 33 | 34 | if (!content) { 35 | return null; 36 | } 37 | 38 | return ( 39 | 40 |
    47 | 48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/DependenciesHeader/DependenciesHeader.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable styled-components-a11y/no-onchange */ 2 | import styled from 'styled-components'; 3 | 4 | import type { Manager } from '../../../../../server/types/dependency.types'; 5 | import { useProjectStore } from '../../../../app/ContextStore'; 6 | import { useAvailableManagers } from '../../../../hooks/use-available-managers'; 7 | import { useMutateReinstall } from '../../../../hooks/use-mutate-reinstall'; 8 | import { useProjectPath } from '../../../../hooks/use-project-path'; 9 | import { Button } from '../../../../ui/Button/Button'; 10 | import { Search } from './Search/Search'; 11 | 12 | const RightSection = styled.div` 13 | float: right; 14 | `; 15 | 16 | interface Props { 17 | isGlobal?: boolean; 18 | } 19 | 20 | const Select = styled.select` 21 | border: 0; 22 | border-radius: 2px; 23 | color: #fff; 24 | font-family: inherit; 25 | font-size: 11px; 26 | font-weight: 500; 27 | outline: none; 28 | padding: 8px; 29 | -webkit-transition: background-color 200ms; 30 | transition: background-color 200ms; 31 | vertical-align: middle; 32 | margin-right: 5px; 33 | white-space: nowrap; 34 | background-color: #d9534f; 35 | font-size: 10px; 36 | padding: 6px; 37 | 38 | &:disabled { 39 | cursor: not-allowed; 40 | background-color: #959595 !important; 41 | } 42 | `; 43 | 44 | export const DependenciesHeader: React.FC = ({ isGlobal }) => { 45 | const projectPath = useProjectPath(); 46 | const { project } = useProjectStore(projectPath); 47 | const availableManagers = useAvailableManagers(); 48 | const reinstallMutation = useMutateReinstall(projectPath); 49 | 50 | return ( 51 |
    52 | 53 | 54 | 55 | {isGlobal !== true && ( 56 | <> 57 | Install: 58 |   59 | 68 | 69 | )} 70 | {isGlobal !== true && ( 71 | <> 72 |     73 | 98 | 99 | )} 100 | 101 |
    102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/DependenciesHeader/Search/Search.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-no-useless-fragment */ 2 | import type { ReactNode, VFC } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | import type { SearchResponse } from '../../../../../../server/types/global.types'; 7 | import { Dropdown } from '../../../../../ui/Dropdown/Drodpown'; 8 | import type { Props as TableProps } from '../../../../../ui/Table/Table'; 9 | import { Table } from '../../../../../ui/Table/Table'; 10 | import { ZERO } from '../../../../../utils'; 11 | import { HomePageCell } from '../../table-cells/HomePageCell'; 12 | import { NpmCell } from '../../table-cells/NpmCell'; 13 | import { RepoCell } from '../../table-cells/RepoCell'; 14 | import { TimeCell } from '../../table-cells/TimeCell'; 15 | import { SearchForm } from './SearchForm'; 16 | import { SearchInstall } from './SearchInstall'; 17 | import { useSearch } from './use-search'; 18 | 19 | const Wrapper = styled.div` 20 | position: relative; 21 | display: inline-block; 22 | z-index: 5; 23 | `; 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-type-alias 26 | const columns: TableProps['columns'] = [ 27 | { 28 | name: 'name', 29 | sortable: true, 30 | render: (result): ReactNode => {result.name}, 31 | }, 32 | { 33 | name: 'version', 34 | label: 'latest', 35 | sortable: true, 36 | render: (searchItem): ReactNode => ( 37 | 38 | ), 39 | }, 40 | { 41 | name: 'score', 42 | sortable: true, 43 | render: (result): ReactNode => `${(result.score * 100).toFixed(2)}%`, 44 | }, 45 | { name: 'updated', sortable: true, render: TimeCell }, 46 | { 47 | name: 'homepage', 48 | label: '', 49 | render: HomePageCell, 50 | }, 51 | { 52 | name: 'repository', 53 | label: '', 54 | render: RepoCell, 55 | }, 56 | { 57 | name: 'npm', 58 | label: '', 59 | render: NpmCell, 60 | }, 61 | ]; 62 | 63 | export const Search: VFC = () => { 64 | const { searchResults, onSearch } = useSearch(); 65 | 66 | return ( 67 | 68 | 69 | {(onToggleOpen): ReactNode => ( 70 | <> 71 | { 73 | onSearch(query); 74 | onToggleOpen(true); 75 | }} 76 | searchResults={searchResults} 77 | /> 78 | 79 | )} 80 | {(): ReactNode => ( 81 | <> 82 | {searchResults.length > ZERO && ( 83 | 90 | )} 91 | 92 | )} 93 | 94 | 95 | ); 96 | }; 97 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/DependenciesHeader/Search/SearchForm.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | import { useState } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import type { SearchResponse } from '../../../../../../server/types/global.types'; 6 | import { Button } from '../../../../../ui/Button/Button'; 7 | import { Link } from '../../../../../ui/Button/Link'; 8 | import { Loader } from '../../../../../ui/Loader'; 9 | 10 | const Form = styled.form` 11 | /* margin-bottom: 6px; 12 | margin-top: 6px; */ 13 | 14 | & > select { 15 | display: inline-block; 16 | width: 5em; 17 | vertical-align: top; 18 | } 19 | 20 | & > input { 21 | display: inline-block; 22 | width: 11em; 23 | vertical-align: top; 24 | } 25 | 26 | & > input:disabled, 27 | & > select:disabled { 28 | background: lightgray; 29 | cursor: not-allowed; 30 | } 31 | `; 32 | 33 | export interface Props { 34 | onSubmit: (query: string) => void; 35 | searchResults: SearchResponse; 36 | } 37 | 38 | export const SearchForm: VFC = ({ onSubmit, searchResults }) => { 39 | const [query, setQuery] = useState(''); 40 | 41 | return ( 42 |
    { 44 | event.preventDefault(); 45 | onSubmit(query); 46 | }} 47 | > 48 | 51 |   52 | { 55 | setQuery(event.currentTarget.value); 56 | }} 57 | placeholder="find a new package" 58 | type="text" 59 | value={query} 60 | /> 61 |   62 | 70 |      71 | 77 | source: npms.io 78 | 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/DependenciesHeader/Search/SearchInstall.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import type { SearchResponse } from '../../../../../../server/types/global.types'; 5 | import { useMutateInstallDependency } from '../../../../../hooks/use-mutate-install-dependency'; 6 | import { Button } from '../../../../../ui/Button/Button'; 7 | import { Dropdown } from '../../../../../ui/Dropdown/Drodpown'; 8 | 9 | interface Props { 10 | searchItem: SearchResponse[number]; 11 | } 12 | 13 | const Row = styled.div` 14 | white-space: nowrap; 15 | `; 16 | 17 | export const SearchInstall: FC = ({ searchItem }) => { 18 | const installDependencyMutation = useMutateInstallDependency(); 19 | 20 | return ( 21 | 22 | {(onToggleOpen): ReactNode => ( 23 | 31 | )} 32 | {(onToggleOpen): ReactNode => ( 33 | 34 |
    35 | 49 | 63 |
    64 | )} 65 |
    66 | ); 67 | }; 68 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/DependenciesHeader/Search/use-search.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | import type { SearchResponse } from '../../../../../../server/types/global.types'; 4 | import { fetchJSON } from '../../../../../service/utils'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 7 | export const useSearch = () => { 8 | const [searchResults, setSearchResults] = useState([]); 9 | 10 | const onSearch = useCallback(async (query) => { 11 | const result = await fetchJSON('/api/search/npm', { 12 | method: 'POST', 13 | body: JSON.stringify({ query }), 14 | }); 15 | 16 | setSearchResults(result); 17 | }, []); 18 | 19 | return { searchResults, onSearch }; 20 | }; 21 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/InstallHeader.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import type { VFC } from 'react'; 3 | 4 | import { useProjectStore } from '../../../app/ContextStore'; 5 | import { useMutateDependencies } from '../../../hooks/use-mutate-dependencies'; 6 | import { useProjectPath } from '../../../hooks/use-project-path'; 7 | import { Button } from '../../../ui/Button/Button'; 8 | 9 | export const InstallHeader: VFC = () => { 10 | const projectPath = useProjectPath(); 11 | const { project } = useProjectStore(projectPath); 12 | const syncDependenciesMutation = useMutateDependencies(projectPath); 13 | 14 | const hasChanges = Object.values(project?.dependenciesMutate || {}).some( 15 | (value) => value?.required || value?.delete, 16 | ); 17 | 18 | return ( 19 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/ToInstallHeader.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | import type React from 'react'; 3 | import { useCallback } from 'react'; 4 | 5 | import { useProjectStore } from '../../../app/ContextStore'; 6 | import { useFullDependencies } from '../../../hooks/use-full-dependencies'; 7 | import { useProjectPath } from '../../../hooks/use-project-path'; 8 | import { Button } from '../../../ui/Button/Button'; 9 | import { useTableFilter } from '../../../ui/Table/use-table-filter'; 10 | import { getNormalizedRequiredVersion } from '../../../utils'; 11 | 12 | interface Props { 13 | version: 'installed' | 'latest' | 'wanted'; 14 | } 15 | 16 | export const ToInstallHeader: VFC = ({ version }) => { 17 | const projectPath = useProjectPath(); 18 | const { dispatch, project } = useProjectStore(projectPath); 19 | 20 | const { dependencies } = useFullDependencies(projectPath); 21 | 22 | const { tableDataFiltered: dependenciesFiltered } = 23 | useTableFilter(dependencies); 24 | 25 | const dependenciesWithVersion = dependenciesFiltered?.filter( 26 | (dependency) => 27 | dependency[version] && 28 | dependency[version] !== getNormalizedRequiredVersion(dependency.required), 29 | ); 30 | 31 | const allChecked = dependenciesWithVersion?.every((dep) => { 32 | return project?.dependenciesMutate?.[dep.name]?.required === dep[version]; 33 | }); 34 | 35 | const onCheck = useCallback( 36 | (event: React.MouseEvent) => { 37 | event.stopPropagation(); 38 | if (dependenciesFiltered) { 39 | for (const dependency of dependenciesFiltered) { 40 | const v = dependency[version]; 41 | if (v && getNormalizedRequiredVersion(dependency.required) !== v) { 42 | dispatch({ 43 | action: 'mutateProjectDependency', 44 | projectPath, 45 | name: dependency.name, 46 | required: v, 47 | type: dependency.type, 48 | delete: null, 49 | }); 50 | } 51 | } 52 | } 53 | }, 54 | [dependenciesFiltered, dispatch, projectPath, version], 55 | ); 56 | 57 | return ( 58 | <> 59 | {version === 'wanted' ? 'compatible' : version}   60 | {dependenciesWithVersion && dependenciesWithVersion.length > 0 && ( 61 | 45 | )} 46 | 47 | {(onToggleOpen): ReactNode => ( 48 | <> 49 |
    50 | 51 | { 55 | setMajor(version); 56 | setMinor(undefined); 57 | }} 58 | selectedVersion={selectedMajor} 59 | versions={versionsMajor} 60 | /> 61 |   62 | { 66 | setMinor(version); 67 | }} 68 | selectedVersion={selectedMinor} 69 | versions={versionsMinor} 70 | /> 71 |   72 | 78 | 79 | 80 | )} 81 | 82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/OtherVersionCell/FindOtherVersions/VersionColumn.tsx: -------------------------------------------------------------------------------- 1 | import type { FC } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import type { DependencyInstalledExtras } from '../../../../../../../server/types/dependency.types'; 5 | import { useProjectStore } from '../../../../../../app/ContextStore'; 6 | import { useProjectPath } from '../../../../../../hooks/use-project-path'; 7 | import { Button } from '../../../../../../ui/Button/Button'; 8 | 9 | const Column = styled.div` 10 | text-align: left; 11 | `; 12 | 13 | const Row = styled.div` 14 | white-space: nowrap; 15 | margin-bottom: 10px; 16 | `; 17 | 18 | interface Props { 19 | selectedVersion?: string; 20 | versions: string[]; 21 | onClick: () => void; 22 | onMouseEnter?: (version: string) => void; 23 | dependency: DependencyInstalledExtras; 24 | } 25 | 26 | export const VersionColumn: FC = ({ 27 | versions, 28 | selectedVersion, 29 | onClick, 30 | onMouseEnter, 31 | dependency, 32 | }) => { 33 | const projectPath = useProjectPath(); 34 | const { dispatch } = useProjectStore(projectPath); 35 | 36 | return ( 37 | 38 | {versions?.map((version) => ( 39 | 40 | 61 | {version === selectedVersion && <> =>} 62 | 63 | ))} 64 | 65 | ); 66 | }; 67 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/OtherVersionCell/FindOtherVersions/use-find-other-version.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-array-reduce */ 2 | import { useEffect, useMemo, useState } from 'react'; 3 | 4 | import type { DependencyInstalledExtras } from '../../../../../../../server/types/dependency.types'; 5 | 6 | export const parseSemVersion = ( 7 | version: string, 8 | ): { major?: string; minor?: string; patch?: string; alfa?: string } => { 9 | const match = version.match( 10 | /^(?\d+)\.(?\d+)\.(?\d+)(-(?.+)){0,1}/, 11 | ); 12 | 13 | return { 14 | major: match?.groups?.['major'], 15 | minor: match?.groups?.['minor'], 16 | patch: match?.groups?.['patch'], 17 | alfa: match?.groups?.['alfa'], 18 | }; 19 | }; 20 | 21 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 22 | export const useFindOtherVersion = (dependency: DependencyInstalledExtras) => { 23 | const [selectedMajor, setMajor] = useState(); 24 | const [selectedMinor, setMinor] = useState(); 25 | const reversedVersions = useMemo( 26 | () => [...(dependency.versions || [])].reverse(), 27 | [dependency.versions], 28 | ); 29 | 30 | // build major versions 31 | const versionsMajor = useMemo( 32 | () => 33 | reversedVersions.reduce((accumulator, version) => { 34 | const { major } = parseSemVersion(version); 35 | 36 | if (major && !accumulator.includes(major)) { 37 | return [...accumulator, major]; 38 | } 39 | 40 | return accumulator; 41 | }, []), 42 | [reversedVersions], 43 | ); 44 | 45 | // build minor versions 46 | const versionsMinor = useMemo( 47 | () => 48 | reversedVersions.reduce((accumulator, version) => { 49 | const { major, minor } = parseSemVersion(version); 50 | const versionString = `${major}.${minor}`; 51 | 52 | if ( 53 | major === selectedMajor && 54 | minor && 55 | !accumulator.includes(versionString) 56 | ) { 57 | return [...accumulator, versionString]; 58 | } 59 | 60 | return accumulator; 61 | }, []), 62 | [reversedVersions, selectedMajor], 63 | ); 64 | 65 | // build patch versions 66 | const versionsPatch = useMemo( 67 | () => 68 | reversedVersions.reduce((accumulator, version) => { 69 | const { major, minor, patch } = parseSemVersion(version); 70 | const versionString = `${major}.${minor}.${patch}`; 71 | 72 | if ( 73 | `${major}.${minor}` === selectedMinor && 74 | patch && 75 | !accumulator.includes(versionString) 76 | ) { 77 | return [...accumulator, versionString]; 78 | } 79 | 80 | return accumulator; 81 | }, []), 82 | [reversedVersions, selectedMinor], 83 | ); 84 | 85 | useEffect(() => { 86 | if (selectedMajor && !selectedMinor && reversedVersions.length > 0) { 87 | const majorFirstVersion = reversedVersions.find((version) => { 88 | const { major } = parseSemVersion(version); 89 | 90 | return selectedMajor === major; 91 | }); 92 | 93 | if (majorFirstVersion) { 94 | const { major, minor } = parseSemVersion(majorFirstVersion); 95 | 96 | setMinor(`${major}.${minor}`); 97 | } 98 | } 99 | }, [reversedVersions, selectedMajor, selectedMinor]); 100 | 101 | useEffect(() => { 102 | const [firstVersion] = reversedVersions; 103 | 104 | if (!selectedMajor && firstVersion) { 105 | const { major } = parseSemVersion(firstVersion); 106 | 107 | setMajor(major); 108 | } 109 | }, [reversedVersions, selectedMajor]); 110 | 111 | return { 112 | versionsMajor, 113 | versionsMinor, 114 | versionsPatch, 115 | setMajor, 116 | setMinor, 117 | selectedMajor, 118 | selectedMinor, 119 | }; 120 | }; 121 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/OtherVersionCell/OtherVersionCell.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | import type { DependencyInstalledExtras } from '../../../../../../server/types/dependency.types'; 4 | import { FindOtherVersion } from './FindOtherVersions/FindOtherVersion'; 5 | 6 | export const OtherVersionCell = ( 7 | dependency: DependencyInstalledExtras, 8 | ): ReactNode => { 9 | return ; 10 | }; 11 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/RepoCell.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | import type { DependencyInstalledExtras } from '../../../../../server/types/dependency.types'; 4 | import { Link } from '../../../../ui/Button/Link'; 5 | import { normalizeRepositoryLink } from '../../../../utils'; 6 | 7 | export const RepoCell = ({ 8 | repository, 9 | }: Pick): ReactNode => { 10 | if (!repository) { 11 | return null; 12 | } 13 | 14 | return ( 15 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/ScoreCell.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | import type { DependencyInstalledExtras } from '../../../../../server/types/dependency.types'; 4 | import { ScoreBadge } from '../../../../ui/ScoreBadge/ScoreBadge'; 5 | 6 | export const ScoreCell = ({ 7 | score, 8 | name, 9 | }: DependencyInstalledExtras): ReactNode => { 10 | if (typeof score !== 'number') { 11 | return null; 12 | } 13 | 14 | return ( 15 | 21 | {score}% 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/SizeCell.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | import type { DependencyInstalledExtras } from '../../../../../server/types/dependency.types'; 4 | import { Link } from '../../../../ui/Button/Link'; 5 | 6 | const ONE_KB = 1024; 7 | const DIGITS = 2; 8 | 9 | export const SizeCell = ({ 10 | name, 11 | installed, 12 | size, 13 | }: DependencyInstalledExtras): ReactNode => { 14 | if (typeof size !== 'number') { 15 | return null; 16 | } 17 | 18 | return ( 19 | 24 | {`${Number.parseFloat(`${size / ONE_KB}`).toFixed(DIGITS)}kB`} 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/TimeCell.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | // eslint-disable-next-line max-statements 5 | export const timeSince = (date: number): string => { 6 | const seconds = Math.floor((Date.now() - date) / 1000); 7 | 8 | let interval = seconds / 31_536_000; 9 | 10 | if (interval > 1) { 11 | return `${Math.floor(interval)} years`; 12 | } 13 | interval = seconds / 2_592_000; 14 | if (interval > 1) { 15 | return `${Math.floor(interval)} months`; 16 | } 17 | interval = seconds / 86_400; 18 | if (interval > 1) { 19 | return `${Math.floor(interval)} days`; 20 | } 21 | interval = seconds / 3600; 22 | if (interval > 1) { 23 | return `${Math.floor(interval)} hours`; 24 | } 25 | interval = seconds / 60; 26 | if (interval > 1) { 27 | return `${Math.floor(interval)} minutes`; 28 | } 29 | return `${Math.floor(seconds)} seconds`; 30 | }; 31 | 32 | const Wrapper = styled.span` 33 | color: gray; 34 | font-size: 0.8em; 35 | `; 36 | 37 | export const TimeCell = (_: unknown, time: unknown): ReactNode => ( 38 | 39 | {time && timeSince(new Date(time as string).getTime())} 40 | 41 | ); 42 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/TypeCell.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | import type { DependencyInstalledExtras } from '../../../../../server/types/dependency.types'; 4 | 5 | export const TypeCell = ({ type }: DependencyInstalledExtras): ReactNode => { 6 | return type !== 'prod' ? type : ''; 7 | }; 8 | -------------------------------------------------------------------------------- /client/components/Project/Dependencies/table-cells/VersionCells/TableVersion.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import type { DependencyInstalledExtras } from '../../../../../../server/types/dependency.types'; 5 | import { useProjectStore } from '../../../../../app/ContextStore'; 6 | import { useProjectPath } from '../../../../../hooks/use-project-path'; 7 | import { Button } from '../../../../../ui/Button/Button'; 8 | import { Loader } from '../../../../../ui/Loader'; 9 | import { getNormalizedRequiredVersion } from '../../../../../utils'; 10 | 11 | const Missing = styled.span` 12 | color: #d9534f; 13 | `; 14 | 15 | interface Props { 16 | dependency: DependencyInstalledExtras; 17 | version: string | null | undefined; 18 | isInstalled?: true; 19 | } 20 | 21 | export const TableVersion: VFC = ({ 22 | dependency, 23 | version, 24 | isInstalled, 25 | }) => { 26 | const projectPath = useProjectPath(); 27 | const { dispatch, project } = useProjectStore(projectPath); 28 | 29 | if (isInstalled) { 30 | if (version === undefined) { 31 | return ; 32 | } 33 | 34 | if (version === null) { 35 | return missing; 36 | } 37 | 38 | if ( 39 | dependency.type === 'global' || 40 | getNormalizedRequiredVersion(dependency.required) === version 41 | ) { 42 | return {version}; 43 | } 44 | } else if (version === null) { 45 | return <>-; 46 | } 47 | 48 | if (typeof version !== 'string') { 49 | return null; 50 | } 51 | 52 | return ( 53 | 74 | ); 75 | }; 76 | -------------------------------------------------------------------------------- /client/components/Project/Project.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | import { useContext } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { ContextStore } from '../../app/ContextStore'; 6 | import { useFastDependencies } from '../../hooks/use-fast-dependencies'; 7 | import { useProjectPath } from '../../hooks/use-project-path'; 8 | import { Loader } from '../../ui/Loader'; 9 | import { Dependencies } from './Dependencies/Dependencies'; 10 | import { ProjectJobs } from './ProjectJobs/ProjectJobs'; 11 | import { useProject } from './use-project'; 12 | 13 | const Wrapper = styled.div` 14 | display: flex; 15 | flex: 1; 16 | padding: 7px 15px; 17 | flex-direction: column; 18 | overflow: hidden; 19 | `; 20 | 21 | const WrapperCenter = styled.div` 22 | display: flex; 23 | flex: 1; 24 | padding: 7px 15px; 25 | flex-direction: column; 26 | align-items: center; 27 | justify-content: center; 28 | overflow: hidden; 29 | `; 30 | 31 | const Text = styled.p` 32 | text-align: center; 33 | max-width: 200px; 34 | `; 35 | 36 | export const Project: VFC = () => { 37 | const projectPath = useProjectPath(); 38 | const { dispatch } = useContext(ContextStore); 39 | const { projectExists } = useProject(projectPath); 40 | 41 | // request for package.json 42 | const { isError, isFetched } = useFastDependencies(projectPath, () => { 43 | if (!projectExists) { 44 | dispatch({ action: 'addProject', projectPath }); 45 | } 46 | }); 47 | 48 | // invalid project 49 | if (isError) { 50 | return ( 51 | 52 | Invalid project({window.atob(projectPath)}) 53 | 54 | Use Open button in top right corner to navigate to project with 55 |   56 | package.json 57 | 58 | 59 | ); 60 | } 61 | 62 | // loading 63 | if (!isFetched || !projectExists) { 64 | return ( 65 | 66 | 67 | Verifying 68 | 69 | ); 70 | } 71 | 72 | // all good 73 | return ( 74 | <> 75 | 76 | 77 | 78 | {/* TODO we need to show pending jobs and their execution time */} 79 | 80 | 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /client/components/Project/ProjectJobs/JobItem.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-type-alias */ 2 | /* eslint-disable @typescript-eslint/no-explicit-any */ 3 | /* eslint-disable @typescript-eslint/naming-convention */ 4 | import type { ComponentProps, VFC } from 'react'; 5 | import { useState } from 'react'; 6 | import styled from 'styled-components'; 7 | 8 | import type { Job } from '../../../app/store.reducer'; 9 | import { Button } from '../../../ui/Button/Button'; 10 | import { Modal } from '../../../ui/Modal/Modal'; 11 | 12 | const CloseButton = styled(Button)` 13 | margin-right: 15px; 14 | margin-left: -3px; 15 | `; 16 | 17 | interface Props { 18 | description: string; 19 | status: Job['status']; 20 | onRemove: () => void; 21 | } 22 | 23 | const getVariantForStatus = ( 24 | status: Job['status'], 25 | ): ComponentProps['variant'] => { 26 | if (status === 'SUCCESS') { 27 | return 'success'; 28 | } 29 | 30 | if (status === 'WORKING') { 31 | return 'info'; 32 | } 33 | 34 | return 'primary'; 35 | }; 36 | 37 | export const JobItem: VFC = ({ description, status, onRemove }) => { 38 | const [detailsOpen, setDetailsOpen] = useState(false); 39 | return ( 40 | <> 41 | 52 | 53 | 60 | 61 | {detailsOpen && ( 62 | { 64 | setDetailsOpen(false); 65 | }} 66 | > 67 | {/*
    {JSON.stringify(stdout, null, INDENT)}
    */} 68 |
    69 | )} 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /client/components/Project/ProjectJobs/ProjectJobs.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import type { VFC } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | import { useProjectsJobs, useProjectStore } from '../../../app/ContextStore'; 6 | import { JobItem } from './JobItem'; 7 | 8 | const TaskQueueWrapper = styled.div` 9 | background: #3e3f3a; 10 | padding: 5px 15px; 11 | display: flex; 12 | `; 13 | 14 | interface Props { 15 | projectPath: string; 16 | } 17 | 18 | export const ProjectJobs: VFC = ({ projectPath }) => { 19 | const { project } = useProjectStore(projectPath); 20 | const { removeJob } = useProjectsJobs(projectPath); 21 | 22 | return ( 23 | 24 | {project?.jobs.map((job) => ( 25 | removeJob(job.id)} 29 | status={job.status} 30 | /> 31 | ))} 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /client/components/Project/use-project.ts: -------------------------------------------------------------------------------- 1 | import { useIsFetching, useIsMutating } from '@tanstack/react-query'; 2 | import { useContext, useEffect } from 'react'; 3 | 4 | import { ContextStore } from '../../app/ContextStore'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 7 | export const useProject = (projectPath: string) => { 8 | const isMutating = useIsMutating([projectPath]) > 0; 9 | const isFetching = useIsFetching([projectPath]) > 0; 10 | 11 | const { 12 | state: { projects }, 13 | dispatch, 14 | } = useContext(ContextStore); 15 | 16 | const projectExists = projects.some( 17 | (project) => project.path === projectPath, 18 | ); 19 | 20 | // useEffect(() => { 21 | // if (!projectExists) { 22 | // dispatch({ action: 'addProject', projectPath }); 23 | // } 24 | // }, [projectPath, projectExists, dispatch]); 25 | 26 | useEffect(() => { 27 | dispatch({ 28 | action: 'busyProject', 29 | projectPath, 30 | isBusy: isMutating || isFetching, 31 | }); 32 | }, [dispatch, isFetching, isMutating, projectPath]); 33 | 34 | return { projectExists }; 35 | }; 36 | -------------------------------------------------------------------------------- /client/hooks/use-available-managers.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | 3 | import type { AvailableManagerResponse } from '../../server/types/global.types'; 4 | import { fetchJSON } from '../service/utils'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 7 | export const useAvailableManagers = () => { 8 | const { data } = useQuery( 9 | ['available-managers'], 10 | () => fetchJSON(`/api/available-managers`), 11 | { refetchOnMount: false, refetchOnWindowFocus: false }, 12 | ); 13 | 14 | return data; 15 | }; 16 | -------------------------------------------------------------------------------- /client/hooks/use-bundle-details.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { useMemo } from 'react'; 3 | 4 | import type { DependencyInstalledExtras } from '../../server/types/dependency.types'; 5 | import { getDependenciesDetails } from '../service/dependencies.service'; 6 | 7 | export const useBundleDetails = ( 8 | dependencies?: DependencyInstalledExtras[], 9 | ): DependencyInstalledExtras[] | undefined => { 10 | const dependenciesToQuery = useMemo(() => { 11 | return dependencies 12 | ?.filter((dep) => dep.installed) 13 | .map((dep) => `${dep.name}@${dep.installed}`); 14 | }, [dependencies]); 15 | 16 | const manager = dependencies?.[0]?.manager; 17 | 18 | const query = useQuery( 19 | ['get-dependencies-details', manager, dependenciesToQuery], 20 | async () => getDependenciesDetails(manager, dependenciesToQuery), 21 | { 22 | refetchOnMount: false, 23 | refetchOnReconnect: false, 24 | refetchOnWindowFocus: false, 25 | }, 26 | ); 27 | 28 | const dependenciesWithDetails = useMemo(() => { 29 | return dependencies?.map((dep) => { 30 | const depDetails = query?.data?.find( 31 | (details) => 32 | details.name === dep.name && details.version === dep.installed, 33 | ); 34 | 35 | if (!depDetails) { 36 | return dep; 37 | } 38 | 39 | return { 40 | ...dep, 41 | size: depDetails.size, 42 | homepage: depDetails.homepage, 43 | repository: depDetails.repository, 44 | updated: depDetails.updated, 45 | created: depDetails.created, 46 | versions: depDetails.versions, 47 | time: depDetails.time, 48 | }; 49 | }); 50 | }, [dependencies, query?.data]); 51 | 52 | return dependenciesWithDetails; 53 | }; 54 | -------------------------------------------------------------------------------- /client/hooks/use-bundle-score.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { useMemo } from 'react'; 3 | 4 | import type { DependencyInstalledExtras } from '../../server/types/dependency.types'; 5 | import { getDependenciesScore } from '../service/dependencies.service'; 6 | 7 | export const useBundleScore = ( 8 | dependencies?: DependencyInstalledExtras[], 9 | ): DependencyInstalledExtras[] | undefined => { 10 | const dependenciesToQuery = useMemo(() => { 11 | return dependencies?.map((dep) => dep.name); 12 | }, [dependencies]); 13 | 14 | const query = useQuery( 15 | ['get-dependencies-score', dependenciesToQuery], 16 | async () => getDependenciesScore(dependenciesToQuery), 17 | { 18 | refetchOnMount: false, 19 | refetchOnReconnect: false, 20 | refetchOnWindowFocus: false, 21 | }, 22 | ); 23 | 24 | const dependenciesWithScore = useMemo(() => { 25 | return dependencies?.map((dep) => { 26 | const depScore = query?.data?.find((score) => score.name === dep.name); 27 | 28 | if (!depScore) { 29 | return dep; 30 | } 31 | 32 | return { 33 | ...dep, 34 | score: depScore.score, 35 | }; 36 | }); 37 | }, [dependencies, query?.data]); 38 | 39 | return dependenciesWithScore; 40 | }; 41 | -------------------------------------------------------------------------------- /client/hooks/use-fast-dependencies.ts: -------------------------------------------------------------------------------- 1 | import { useIsMutating, useQuery } from '@tanstack/react-query'; 2 | 3 | // import { useProjectsJobs } from '../app/ContextStore'; 4 | import { getProjectDependenciesFast } from '../service/dependencies.service'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 7 | export const useFastDependencies = ( 8 | projectPath: string, 9 | onSuccess?: () => void, 10 | ) => { 11 | // const { startJob, successJob } = useProjectsJobs(projectPath); 12 | 13 | const isProjectMutating = useIsMutating([projectPath]) > 0; 14 | 15 | const query = useQuery( 16 | [projectPath, 'get-project-dependencies', 'fast'], 17 | async () => { 18 | // const id = startJob('Get project dependencies fast'); 19 | 20 | const dependencies = await getProjectDependenciesFast(projectPath); 21 | 22 | // successJob(id); 23 | 24 | return dependencies; 25 | }, 26 | { 27 | refetchOnWindowFocus: false, 28 | refetchOnMount: false, 29 | enabled: !isProjectMutating, 30 | retry: false, 31 | onSuccess, 32 | }, 33 | ); 34 | 35 | return { dependencies: query.data, ...query }; 36 | }; 37 | -------------------------------------------------------------------------------- /client/hooks/use-full-dependencies.ts: -------------------------------------------------------------------------------- 1 | import { useIsMutating, useQuery } from '@tanstack/react-query'; 2 | 3 | import { useProjectsJobs } from '../app/ContextStore'; 4 | import { getProjectDependenciesFull } from '../service/dependencies.service'; 5 | 6 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 7 | export const useFullDependencies = (projectPath: string) => { 8 | const { startJob, successJob } = useProjectsJobs(projectPath); 9 | 10 | const isProjectMutating = useIsMutating([projectPath]) > 0; 11 | 12 | const query = useQuery( 13 | [projectPath, 'get-project-dependencies', 'full'], 14 | async () => { 15 | const id = startJob('Get project dependencies full'); 16 | 17 | const dependencies = await getProjectDependenciesFull(projectPath); 18 | 19 | successJob(id); 20 | 21 | return dependencies; 22 | }, 23 | { 24 | refetchOnWindowFocus: false, 25 | refetchOnMount: false, 26 | enabled: !isProjectMutating, 27 | retry: false, 28 | }, 29 | ); 30 | 31 | return { dependencies: query.data, ...query }; 32 | }; 33 | -------------------------------------------------------------------------------- /client/hooks/use-mutate-dependencies.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-statements */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import { useMutation } from '@tanstack/react-query'; 4 | 5 | import { useProjectsJobs, useProjectStore } from '../app/ContextStore'; 6 | import { 7 | deleteDependencies, 8 | deleteGlobalDependencies, 9 | installDependencies, 10 | installGlobalDependencies, 11 | } from '../service/dependencies.service'; 12 | 13 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 14 | export const useMutateDependencies = (projectPath: string) => { 15 | const { project, dispatch } = useProjectStore(projectPath); 16 | const { startJob, successJob } = useProjectsJobs(projectPath); 17 | 18 | return useMutation([projectPath, 'sync-dependencies'], async () => { 19 | if (!project) { 20 | return; 21 | } 22 | 23 | const id = startJob('Synchronizing dependencies changes'); 24 | 25 | const { dependenciesMutate } = project; 26 | 27 | // eslint-disable-next-line unicorn/consistent-destructuring 28 | if (project.path === 'global') { 29 | // delete global 30 | const delDependencies = Object.entries(dependenciesMutate || {}) 31 | .filter(([_, value]) => value.type === 'global' && value.delete) 32 | .map(([name]) => ({ name })); 33 | if (delDependencies.length > 0) { 34 | await deleteGlobalDependencies(delDependencies); 35 | } 36 | 37 | // install global 38 | const dependencies = Object.entries(dependenciesMutate || {}) 39 | .filter( 40 | ([_, value]) => value.type === 'global' && value.required !== null, 41 | ) 42 | .map(([name, value]) => ({ 43 | name, 44 | version: value.required || undefined, 45 | })); 46 | if (dependencies.length > 0) { 47 | await installGlobalDependencies(dependencies); 48 | } 49 | } else { 50 | // delete dev 51 | const delDevelopmentDependencies = Object.entries( 52 | dependenciesMutate || {}, 53 | ) 54 | .filter(([_, value]) => value.type === 'dev' && value.delete) 55 | .map(([name]) => ({ name })); 56 | if (delDevelopmentDependencies.length > 0) { 57 | await deleteDependencies( 58 | projectPath, 59 | 'dev', 60 | delDevelopmentDependencies, 61 | ); 62 | } 63 | 64 | // delete prod 65 | const delProductionDependencies = Object.entries(dependenciesMutate || {}) 66 | .filter(([_, value]) => value.type === 'prod' && value.delete) 67 | .map(([name]) => ({ name })); 68 | if (delProductionDependencies.length > 0) { 69 | await deleteDependencies( 70 | projectPath, 71 | 'prod', 72 | delProductionDependencies, 73 | ); 74 | } 75 | 76 | // install dev 77 | const devDependencies = Object.entries(dependenciesMutate || {}) 78 | .filter(([_, value]) => value.type === 'dev' && value.required !== null) 79 | .map(([name, value]) => ({ 80 | name, 81 | version: value.required || undefined, 82 | })); 83 | if (devDependencies.length > 0) { 84 | await installDependencies(projectPath, 'dev', devDependencies); 85 | } 86 | 87 | // install prod 88 | const dependencies = Object.entries(dependenciesMutate || {}) 89 | .filter(([_, value]) => value.type === 'prod' && value.required) 90 | .map(([name, value]) => ({ 91 | name, 92 | version: value.required || undefined, 93 | })); 94 | if (dependencies.length > 0) { 95 | await installDependencies(projectPath, 'prod', dependencies); 96 | } 97 | } 98 | 99 | dispatch({ action: 'mutateProjectDependencyReset', projectPath }); 100 | 101 | successJob(id); 102 | }); 103 | }; 104 | -------------------------------------------------------------------------------- /client/hooks/use-mutate-install-dependency.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { useMutation } from '@tanstack/react-query'; 3 | 4 | import type { Basic } from '../../server/types/dependency.types'; 5 | import { useProjectsJobs, useProjectStore } from '../app/ContextStore'; 6 | import { installDependencies } from '../service/dependencies.service'; 7 | import { useProjectPath } from './use-project-path'; 8 | 9 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 10 | export const useMutateInstallDependency = () => { 11 | const projectPath = useProjectPath(); 12 | const { project } = useProjectStore(projectPath); 13 | const { startJob, successJob } = useProjectsJobs(projectPath); 14 | 15 | return useMutation( 16 | [projectPath, 'install-dependency'], 17 | async (dependency: Basic) => { 18 | if (!project) { 19 | return; 20 | } 21 | 22 | const id = startJob( 23 | `Installing new project dependencies: ${dependency.name}`, 24 | ); 25 | 26 | if (dependency.type) { 27 | await installDependencies(projectPath, dependency.type, [dependency]); 28 | } 29 | 30 | successJob(id); 31 | }, 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /client/hooks/use-mutate-reinstall.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { useMutation } from '@tanstack/react-query'; 3 | 4 | import type { Manager } from '../../server/types/dependency.types'; 5 | import { useProjectsJobs, useProjectStore } from '../app/ContextStore'; 6 | import { reinstall } from '../service/dependencies.service'; 7 | 8 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type 9 | export const useMutateReinstall = (projectPath: string) => { 10 | const { project } = useProjectStore(projectPath); 11 | const { startJob, successJob } = useProjectsJobs(projectPath); 12 | 13 | return useMutation([projectPath, 'reinstall'], async (manager?: Manager) => { 14 | if (!project) { 15 | return; 16 | } 17 | 18 | const id = startJob( 19 | `Reinstalling project dependencies ${ 20 | manager ? ` using: ${manager}` : '' 21 | }`, 22 | ); 23 | 24 | await reinstall(projectPath, manager); 25 | 26 | successJob(id); 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /client/hooks/use-project-path.ts: -------------------------------------------------------------------------------- 1 | import { useParams } from 'react-router-dom'; 2 | 3 | export const useProjectPath = (): string => { 4 | const { projectPathEncoded } = useParams<{ projectPathEncoded?: string }>(); 5 | return projectPathEncoded || 'global'; 6 | }; 7 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NPM Graphic User Interface 6 | 10 | 11 | 12 | 13 | 14 |
    15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /* eslint-disable import/no-unassigned-import */ 3 | import './base.css'; 4 | 5 | import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'; 6 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 7 | import { persistQueryClient } from '@tanstack/react-query-persist-client'; 8 | import React from 'react'; 9 | import { createRoot } from 'react-dom/client'; 10 | 11 | import { App } from './components/App'; 12 | 13 | const queryClient = new QueryClient(); 14 | 15 | const localStoragePersister = createSyncStoragePersister({ 16 | storage: window.localStorage, 17 | }); 18 | 19 | persistQueryClient({ 20 | queryClient, 21 | persister: localStoragePersister, 22 | // 60 min 23 | maxAge: 60 * 60 * 1000, 24 | dehydrateOptions: { 25 | shouldDehydrateQuery: ({ queryKey }) => { 26 | // persist only score and package details 27 | const [firstKey] = queryKey; 28 | return ( 29 | typeof firstKey === 'string' && 30 | ['get-dependencies-score', 'get-dependencies-details'].includes( 31 | firstKey, 32 | ) 33 | ); 34 | }, 35 | }, 36 | }); 37 | 38 | const container = document.querySelector('.npm-gui'); 39 | 40 | if (container) { 41 | const root = createRoot(container); 42 | 43 | root.render( 44 | 45 | 46 | 47 | 48 | , 49 | ); 50 | } 51 | 52 | if (window.localStorage.getItem('npm-gui-id') === null) { 53 | window.localStorage.setItem('npm-gui-id', Date.now().toString()); 54 | } 55 | -------------------------------------------------------------------------------- /client/service/dependencies-cache.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prefer-destructuring */ 2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 3 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 4 | 5 | import type { BundleDetails } from '../../server/types/dependency.types'; 6 | 7 | interface CacheDetails { 8 | time: number; 9 | details: Record; 10 | } 11 | 12 | export const getDetailsFromCache = () => { 13 | const cache = localStorage.getItem('details-cache'); 14 | 15 | if (!cache) { 16 | return {}; 17 | } 18 | 19 | const { details } = JSON.parse(cache) as CacheDetails; 20 | 21 | return details; 22 | }; 23 | 24 | export const putDetailsToCache = (putDetails: BundleDetails[]): void => { 25 | const cache = localStorage.getItem('details-cache'); 26 | 27 | let time = undefined; 28 | let previousDetails: Record = {}; 29 | 30 | if (cache) { 31 | const parsed = JSON.parse(cache) as CacheDetails; 32 | time = parsed.time; 33 | previousDetails = parsed.details; 34 | } else { 35 | time = Date.now(); 36 | } 37 | 38 | localStorage.setItem( 39 | 'details-cache', 40 | JSON.stringify({ 41 | time, 42 | details: { 43 | ...previousDetails, 44 | ...Object.fromEntries( 45 | putDetails.map((det) => [`${det.name}@${det.version}`, det]), 46 | ), 47 | }, 48 | } as CacheDetails), 49 | ); 50 | }; 51 | -------------------------------------------------------------------------------- /client/service/dependencies.service.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Basic, 3 | BundleDetails, 4 | BundleScore, 5 | DependencyInstalled, 6 | Manager, 7 | Type, 8 | } from '../../server/types/dependency.types'; 9 | import { xCacheId } from '../xcache'; 10 | import { fetchJSON, getBasePathFor } from './utils'; 11 | 12 | export const getProjectDependenciesFast = async ( 13 | projectPath: string, 14 | ): Promise => { 15 | return fetchJSON(`${getBasePathFor(projectPath)}/simple`); 16 | }; 17 | 18 | export const getProjectDependenciesFull = async ( 19 | projectPath: string, 20 | ): Promise => { 21 | return fetchJSON(`${getBasePathFor(projectPath)}/full`, { 22 | headers: { 'x-cache-id': xCacheId }, 23 | }); 24 | }; 25 | 26 | export const getDependenciesScore = async ( 27 | dependenciesToQuery?: string[], 28 | ): Promise => { 29 | return dependenciesToQuery && dependenciesToQuery.length > 0 30 | ? await fetchJSON( 31 | `/api/score/${dependenciesToQuery.join(',')}`, 32 | ) 33 | : []; 34 | }; 35 | 36 | export const getDependenciesDetails = async ( 37 | manager?: Manager, 38 | dependenciesToQuery?: string[], 39 | ): Promise => { 40 | return manager && dependenciesToQuery && dependenciesToQuery.length > 0 41 | ? await fetchJSON( 42 | `/api/details/${manager}/${dependenciesToQuery.join(',')}`, 43 | ) 44 | : []; 45 | }; 46 | 47 | export const installDependencies = async ( 48 | projectPath: string, 49 | type: Type, 50 | dependencies: Basic[], 51 | ): Promise => { 52 | return fetchJSON(`${getBasePathFor(projectPath)}/${type}`, { 53 | method: 'POST', 54 | body: JSON.stringify(dependencies), 55 | headers: { 'x-cache-id': xCacheId }, 56 | }); 57 | }; 58 | 59 | export const installGlobalDependencies = async ( 60 | dependencies: Basic[], 61 | ): Promise => { 62 | return fetchJSON(`${getBasePathFor('global')}`, { 63 | method: 'POST', 64 | body: JSON.stringify(dependencies), 65 | headers: { 'x-cache-id': xCacheId }, 66 | }); 67 | }; 68 | 69 | export const reinstall = async ( 70 | projectPath: string, 71 | manager?: Manager, 72 | ): Promise => { 73 | return fetchJSON( 74 | `${getBasePathFor(projectPath)}/install${manager ? `/${manager}` : ''}`, 75 | { method: 'POST', headers: { 'x-cache-id': xCacheId } }, 76 | ); 77 | }; 78 | 79 | export const deleteDependencies = async ( 80 | projectPath: string, 81 | type: Type, 82 | dependencies: Basic[], 83 | ): Promise => { 84 | return fetchJSON(`${getBasePathFor(projectPath)}/${type}`, { 85 | method: 'DELETE', 86 | body: JSON.stringify(dependencies), 87 | headers: { 'x-cache-id': xCacheId }, 88 | }); 89 | }; 90 | 91 | export const deleteGlobalDependencies = async ( 92 | dependencies: Basic[], 93 | ): Promise => { 94 | return fetchJSON(`${getBasePathFor('global')}`, { 95 | method: 'DELETE', 96 | body: JSON.stringify(dependencies), 97 | headers: { 'x-cache-id': xCacheId }, 98 | }); 99 | }; 100 | -------------------------------------------------------------------------------- /client/service/utils.ts: -------------------------------------------------------------------------------- 1 | export const getBasePathFor = (projectPath: string): string => { 2 | if (projectPath !== 'global') { 3 | return `/api/project/${projectPath}/dependencies`; 4 | } 5 | 6 | return 'api/global/dependencies'; 7 | }; 8 | 9 | export const fetchJSON = async ( 10 | ...parameters: Parameters 11 | ): Promise => { 12 | const response = await fetch(...parameters); 13 | 14 | if (!response.ok) { 15 | throw new Error('Request Error'); 16 | } 17 | 18 | return response.json(); 19 | }; 20 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-turbocharge/react/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /client/ui/Button/Button.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef, ReactNode } from 'react'; 2 | import { Children } from 'react'; 3 | import { useHistory } from 'react-router-dom'; 4 | import styled, { css } from 'styled-components'; 5 | 6 | import type { CSSType } from '../../Styled'; 7 | import type { Props as IconPropsOriginal } from '../Icon/Icon'; 8 | import { Icon } from '../Icon/Icon'; 9 | 10 | export interface Props extends ComponentPropsWithoutRef<'button'> { 11 | variant: 'danger' | 'dark' | 'info' | 'primary' | 'success' | 'warning'; 12 | icon?: React.ComponentProps['glyph']; 13 | navigate?: string; 14 | title: string; 15 | children: ReactNode; 16 | } 17 | 18 | const variantToColor = { 19 | danger: '#d9534f', 20 | dark: '#3e3f3a', 21 | info: '#1b8dbb', 22 | primary: '#325d88', 23 | success: '#79a736', 24 | warning: '#ef5c0e', 25 | }; 26 | 27 | const ButtonStyled = styled.button` 28 | border: 0; 29 | border-radius: 2px; 30 | color: #fff; 31 | font-family: inherit; 32 | font-size: 10px; 33 | font-weight: 500; 34 | outline: none; 35 | padding: 4px 6px; 36 | transition: background-color 200ms; 37 | vertical-align: middle; 38 | margin-right: 5px; 39 | white-space: nowrap; 40 | 41 | &:first-child { 42 | margin-left: 0; 43 | } 44 | 45 | &:last-child { 46 | margin-right: 0; 47 | } 48 | 49 | &:hover { 50 | filter: brightness(90%); 51 | } 52 | 53 | &:active { 54 | filter: brightness(80%); 55 | } 56 | 57 | ${({ variant }: Readonly): CSSType => css` 58 | background-color: ${variantToColor[variant]}; 59 | `} 60 | 61 | &:disabled { 62 | cursor: not-allowed; 63 | background-color: #959595 !important; 64 | } 65 | `; 66 | 67 | interface IconProps extends IconPropsOriginal { 68 | isAlone: boolean; 69 | } 70 | 71 | const ButtonIcon = styled(Icon)` 72 | margin-right: 3px; 73 | vertical-align: middle; 74 | path { 75 | fill: white; 76 | } 77 | 78 | svg { 79 | color: blue; /* Or any color of your choice. */ 80 | } 81 | 82 | ${({ isAlone }: Readonly): CSSType => 83 | isAlone && 84 | css` 85 | margin-right: 0; 86 | `} 87 | `; 88 | 89 | export const Button: React.FC = ({ 90 | icon, 91 | children, 92 | navigate, 93 | ...props 94 | }) => { 95 | const history = useHistory(); 96 | 97 | if (navigate) { 98 | props.onClick = (): void => history.push(navigate); 99 | } 100 | 101 | return ( 102 | // eslint-disable-next-line react/jsx-props-no-spreading 103 | 104 | {icon !== undefined && ( 105 | 109 | )} 110 | {icon && children ? ` ${children}` : children} 111 | 112 | ); 113 | }; 114 | -------------------------------------------------------------------------------- /client/ui/Button/Link.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import type { ComponentPropsWithoutRef, FC, ReactNode } from 'react'; 3 | import type { SimpleInterpolation } from 'styled-components'; 4 | import styled, { css } from 'styled-components'; 5 | 6 | import { Icon } from '../Icon/Icon'; 7 | 8 | interface Props extends ComponentPropsWithoutRef<'a'> { 9 | variant?: 'danger'; 10 | icon?: React.ComponentProps['glyph']; 11 | title: string; 12 | children: ReactNode; 13 | } 14 | 15 | const StyledLink = styled.a>` 16 | text-decoration: none; 17 | font-weight: bold; 18 | font-size: 0.8em; 19 | display: inline-block; 20 | min-width: 1.6em; 21 | min-height: 1.6em; 22 | text-align: center; 23 | 24 | ${({ variant }): SimpleInterpolation => 25 | variant === 'danger' 26 | ? css` 27 | border-radius: 2px; 28 | color: #fff; 29 | padding: 0.2em 0.4em; 30 | background: #ef5c0e; 31 | ` 32 | : css` 33 | color: gray; 34 | background: transparent; 35 | `} 36 | `; 37 | 38 | export const Link: FC = ({ children, icon, ...props }) => { 39 | return ( 40 | 41 | {icon && } 42 | {children} 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /client/ui/Dropdown/Drodpown.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react'; 2 | import { useCallback, useState } from 'react'; 3 | import styled, { css } from 'styled-components'; 4 | 5 | import type { CSSType } from '../../Styled'; 6 | import { useClickOutsideRef } from '../hooks/use-click-outside'; 7 | 8 | const Wrapper = styled.div` 9 | position: relative; 10 | display: inline-block; 11 | `; 12 | 13 | const Invisible = styled.div` 14 | pointer-events: none; 15 | visibility: hidden; 16 | `; 17 | 18 | const Trigger = styled.div` 19 | position: relative; 20 | 21 | ${({ isOpen }: { isOpen: boolean }): CSSType => 22 | isOpen && 23 | css` 24 | z-index: 3; 25 | `} 26 | `; 27 | 28 | const Content = styled.div` 29 | background: transparent; 30 | max-height: 0; 31 | max-width: 0; 32 | overflow: hidden; 33 | padding: 7.5px; 34 | top: -7.5px; 35 | left: -7.5px; 36 | position: absolute; 37 | z-index: 1; 38 | transition: max-width 300ms, max-height 300ms; 39 | 40 | ${({ isOpen }: { isOpen: boolean }): CSSType => 41 | isOpen && 42 | css` 43 | border: 1px solid #fff; 44 | border-radius: 2px; 45 | background: #fff; 46 | border-color: #dfd7ca; 47 | max-height: 1000px; 48 | max-width: 1000px; 49 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.5); 50 | `} 51 | `; 52 | 53 | interface Props { 54 | children: [ 55 | (onToggleOpen: (forceState?: boolean) => void) => ReactNode, 56 | (onToggleOpen: (forceState?: boolean) => void) => ReactNode, 57 | ]; 58 | } 59 | 60 | export const Dropdown: FC = ({ children }) => { 61 | const [isOpen, setIsOpen] = useState(false); 62 | 63 | const onClose = useCallback(() => { 64 | setIsOpen(false); 65 | }, []); 66 | 67 | const onToggleOpen = useCallback((forceState?: boolean) => { 68 | setIsOpen((previousIsOpen) => 69 | forceState !== undefined ? forceState : !previousIsOpen, 70 | ); 71 | }, []); 72 | 73 | const ref = useClickOutsideRef(onClose); 74 | 75 | return ( 76 | 77 | {children[0](onToggleOpen)} 78 | 79 | {children[0](onToggleOpen)} 80 | {children[1](onToggleOpen)} 81 | 82 | 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /client/ui/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import caretRight from 'open-iconic/svg/caret-right.svg'; 2 | import check from 'open-iconic/svg/check.svg'; 3 | import cloudDownload from 'open-iconic/svg/cloud-download.svg'; 4 | import code from 'open-iconic/svg/code.svg'; 5 | import dataTransferDownload from 'open-iconic/svg/data-transfer-download.svg'; 6 | import folder from 'open-iconic/svg/folder.svg'; 7 | import fork from 'open-iconic/svg/fork.svg'; 8 | import globe from 'open-iconic/svg/globe.svg'; 9 | import home from 'open-iconic/svg/home.svg'; 10 | import reload from 'open-iconic/svg/reload.svg'; 11 | import trash from 'open-iconic/svg/trash.svg'; 12 | import x from 'open-iconic/svg/x.svg'; 13 | import type { HTMLAttributes } from 'react'; 14 | import styled from 'styled-components'; 15 | 16 | import type { CSSType } from '../../Styled'; 17 | 18 | const icons = { 19 | globe, 20 | code, 21 | 'caret-right': caretRight, 22 | x, 23 | folder, 24 | check, 25 | 'data-transfer-download': dataTransferDownload, 26 | 'cloud-download': cloudDownload, 27 | home, 28 | trash, 29 | fork, 30 | reload, 31 | }; 32 | 33 | export interface Props extends HTMLAttributes { 34 | glyph: keyof typeof icons; 35 | } 36 | 37 | export const Icon = styled.i` 38 | display: inline-block; 39 | width: 1em; 40 | height: 1em; 41 | background-color: white; 42 | background: url(${({ glyph }): CSSType => icons[glyph]}); 43 | background-size: contain; 44 | background-repeat: no-repeat; 45 | margin-bottom: 2px; 46 | filter: invert(100%); 47 | `; 48 | -------------------------------------------------------------------------------- /client/ui/Loader.tsx: -------------------------------------------------------------------------------- 1 | import type { VFC } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import { Icon } from './Icon/Icon'; 5 | 6 | const LoaderStyled = styled.span` 7 | animation: spin 1s linear infinite; 8 | display: inline-block; 9 | vertical-align: middle; 10 | 11 | @keyframes spin { 12 | 100% { 13 | transform: rotate(360deg); 14 | } 15 | } 16 | 17 | i { 18 | margin-bottom: 0; 19 | filter: unset; 20 | } 21 | `; 22 | 23 | export const Loader: VFC = () => ( 24 | 25 | 26 | 27 | ); 28 | -------------------------------------------------------------------------------- /client/ui/Modal/Modal.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | const Backdrop = styled.div` 5 | background: rgba(0, 0, 0, 0.2); 6 | display: flex; 7 | align-items: center; 8 | justify-content: center; 9 | position: absolute; 10 | left: 0; 11 | right: 0; 12 | top: 0; 13 | bottom: 0; 14 | z-index: 5; 15 | `; 16 | 17 | const ModalBody = styled.div` 18 | background: white; 19 | min-width: 400px; 20 | max-width: 80vw; 21 | min-height: 400px; 22 | max-height: 80vh; 23 | overflow-y: scroll; 24 | padding: 10px; 25 | `; 26 | 27 | interface Props { 28 | onClose: () => void; 29 | children: ReactNode; 30 | } 31 | 32 | export const Modal: React.FC = ({ children, onClose }) => ( 33 | 34 | {children} 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /client/ui/ScoreBadge/ScoreBadge.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import type { CSSType } from '../../Styled'; 4 | 5 | export const ScoreBadge = styled.a<{ score?: number }>` 6 | color: white; 7 | text-decoration: none; 8 | font-weight: 100; 9 | padding: 3px 5px; 10 | border-radius: 2px; 11 | 12 | ${({ score }): CSSType => score && score >= 85 && 'color: #4c1;'} 13 | ${({ score }): CSSType => score && score < 85 && 'background: #dbab09;'} 14 | ${({ score }): CSSType => score && score < 70 && 'background: #e05d44;'} 15 | `; 16 | -------------------------------------------------------------------------------- /client/ui/Table/Table.tsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components'; 2 | 3 | import type { CSSType } from '../../Styled'; 4 | import { Loader } from '../Loader'; 5 | import type { IColumn, TableRowAbstract } from './components/TbodyRow'; 6 | import { TbodyRow } from './components/TbodyRow'; 7 | import { Th } from './components/Th'; 8 | import { useTableSort } from './use-table-sort'; 9 | 10 | export interface Props { 11 | columns: IColumn[]; 12 | // data 13 | tableData?: T[]; 14 | isEmpty: boolean; 15 | // filter 16 | filters: Record; 17 | onFilterChange?: (columnName: string, newFilterValue: string) => void; 18 | // other 19 | maxHeight?: string; 20 | } 21 | 22 | const Wrapper = styled.div<{ maxHeight?: string }>` 23 | border: 1px solid #dfd7ca; 24 | border-radius: 2px; 25 | margin-top: 7px; 26 | flex: 1; 27 | position: relative; 28 | overflow-y: scroll; 29 | 30 | ${({ maxHeight }): CSSType => 31 | maxHeight 32 | ? css` 33 | max-height: ${maxHeight}; 34 | ` 35 | : ''} 36 | `; 37 | 38 | const Info = styled.div` 39 | position: absolute; 40 | text-align: center; 41 | left: 0; 42 | right: 0; 43 | top: 0; 44 | bottom: 0; 45 | display: flex; 46 | pointer-events: none; 47 | `; 48 | 49 | const Thead = styled.thead` 50 | position: sticky; 51 | top: 0; 52 | background: white; 53 | box-shadow: inset 0 -2px 0 #dfd7ca; 54 | z-index: 1; 55 | `; 56 | 57 | const Tbody = styled.tbody` 58 | td { 59 | padding: 3px; 60 | } 61 | `; 62 | 63 | export const Table = ({ 64 | columns, 65 | filters, 66 | onFilterChange, 67 | isEmpty, 68 | tableData, 69 | maxHeight, 70 | }: // eslint-disable-next-line @typescript-eslint/ban-types 71 | Props): JSX.Element => { 72 | const { sort, sortReversed, onSortChange, tableDataSorted } = 73 | useTableSort(tableData); 74 | 75 | return ( 76 | 77 | 78 | {isEmpty && <>empty...} 79 | 80 | {!tableData && ( 81 | <> 82 | 83 |  loading... 84 | 85 | )} 86 | 87 | 88 |
    89 | 90 | 91 | {columns.map((column) => { 92 | return ( 93 | 113 | ); 114 | })} 115 | 116 | 117 | 118 | 119 | {tableDataSorted?.map((row) => ( 120 | 121 | ))} 122 | 123 |
    100 | onFilterChange(column.name, newFilterValue) 101 | : undefined 102 | } 103 | onSortChange={ 104 | column.sortable 105 | ? (): void => onSortChange(column.name) 106 | : undefined 107 | } 108 | sortActive={column.name === sort} 109 | sortReversed={sortReversed} 110 | > 111 | {column.label !== undefined ? column.label : column.name} 112 |
    124 |
    125 | ); 126 | }; 127 | -------------------------------------------------------------------------------- /client/ui/Table/components/SelectFilter.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable styled-components-a11y/no-onchange */ 2 | /* eslint-disable @typescript-eslint/ban-types */ 3 | import styled from 'styled-components'; 4 | 5 | import type { FilterProps } from './shared'; 6 | import { preventEvent } from './shared'; 7 | 8 | const Select = styled.select` 9 | display: inline-block; 10 | height: 15px; 11 | vertical-align: middle; 12 | padding: 0; 13 | `; 14 | 15 | export const SelectFilter = ({ 16 | selectedValue, 17 | onFilterChange, 18 | }: FilterProps): JSX.Element => ( 19 | 30 | ); 31 | -------------------------------------------------------------------------------- /client/ui/Table/components/TbodyRow.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | export interface TableRowAbstract { 4 | [key: string]: unknown; 5 | name: string; 6 | hideBottomBorder?: true; 7 | drawFolder?: true; 8 | } 9 | 10 | export interface IColumn { 11 | name: string; 12 | label?: ReactNode; 13 | sortable?: true; 14 | filterable?: string[] | true; 15 | render?: (row: T, abs: unknown) => ReactNode; 16 | } 17 | 18 | interface Props { 19 | row: T; 20 | columns: IColumn[]; 21 | } 22 | 23 | export const TbodyRow = ({ 24 | row, 25 | columns, 26 | }: // eslint-disable-next-line @typescript-eslint/ban-types 27 | Props): JSX.Element => { 28 | return ( 29 | 30 | {columns.map((column) => { 31 | return ( 32 | 36 | {column.render 37 | ? column.render(row, row[column.name]) 38 | : row[column.name]} 39 | 40 | ); 41 | })} 42 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /client/ui/Table/components/TextFilter.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | import type { FilterProps } from './shared'; 4 | import { preventEvent } from './shared'; 5 | 6 | const Input = styled.input` 7 | display: inline-block; 8 | height: 15px; 9 | vertical-align: middle; 10 | padding: 0; 11 | width: 4em; 12 | `; 13 | 14 | export const TextFilter = ({ 15 | selectedValue, 16 | onFilterChange, 17 | }: // eslint-disable-next-line @typescript-eslint/ban-types 18 | FilterProps): JSX.Element => ( 19 | { 21 | onFilterChange(event.target.value as T); 22 | }} 23 | onClick={preventEvent} 24 | type="text" 25 | value={selectedValue} 26 | /> 27 | ); 28 | -------------------------------------------------------------------------------- /client/ui/Table/components/Th.tsx: -------------------------------------------------------------------------------- 1 | import type { FC, ReactNode } from 'react'; 2 | import type { CSSProp } from 'styled-components'; 3 | import styled, { css } from 'styled-components'; 4 | 5 | import { Icon } from '../../Icon/Icon'; 6 | import { SelectFilter } from './SelectFilter'; 7 | import { TextFilter } from './TextFilter'; 8 | 9 | interface WrapperProps { 10 | onClick?: unknown; 11 | } 12 | 13 | const Wrapper = styled.th` 14 | ${({ onClick }: WrapperProps): CSSProp => 15 | onClick 16 | ? css` 17 | cursor: pointer; 18 | user-select: none; 19 | ` 20 | : ''} 21 | `; 22 | 23 | const SortableIcon = styled(Icon)` 24 | position: absolute; 25 | margin-left: -1.2em; 26 | `; 27 | 28 | export interface Props { 29 | children?: ReactNode; 30 | 31 | sortActive?: boolean; 32 | sortReversed?: boolean; 33 | onSortChange?: () => void; 34 | 35 | filterable?: string[] | true; 36 | filterValue?: string; 37 | onFilterChange?: (newFilterValue: string) => void; 38 | } 39 | 40 | export const Th: FC = ({ 41 | children, 42 | filterable, 43 | sortActive, 44 | sortReversed, 45 | onSortChange, 46 | filterValue = '', 47 | onFilterChange, 48 | }) => ( 49 | 50 | {sortActive && ( 51 | 54 | )} 55 | {children} 56 | {children && <> } 57 | {onFilterChange && ( 58 | <> 59 | {!Array.isArray(filterable) && ( 60 | 64 | )} 65 | 66 | {Array.isArray(filterable) && ( 67 | 71 | )} 72 | 73 | )} 74 | 75 | ); 76 | -------------------------------------------------------------------------------- /client/ui/Table/components/shared.tsx: -------------------------------------------------------------------------------- 1 | export interface FilterProps { 2 | selectedValue: T; 3 | onFilterChange: (newValue: T) => void; 4 | } 5 | 6 | export const preventEvent = ( 7 | event: React.MouseEvent, 8 | ): void => { 9 | event.stopPropagation(); 10 | event.preventDefault(); 11 | }; 12 | -------------------------------------------------------------------------------- /client/ui/Table/use-table-filter.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 2 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 3 | import { useMemo, useState } from 'react'; 4 | import { useBetween } from 'use-between'; 5 | 6 | const useFiltersState = () => { 7 | return useState>({}); 8 | }; 9 | 10 | export const useSharedFiltersState = () => useBetween(useFiltersState); 11 | 12 | export const useTableFilter = < 13 | T extends { [key: string]: unknown; name: string }, 14 | >( 15 | tableData?: T[], 16 | ) => { 17 | const [filters, setFilters] = useSharedFiltersState(); 18 | 19 | const setFilterValue = (columnName: string, newFilterValue: string): void => { 20 | setFilters((previousFilters) => ({ 21 | ...previousFilters, 22 | [columnName]: newFilterValue, 23 | })); 24 | }; 25 | 26 | const tableDataFiltered = useMemo( 27 | () => 28 | tableData?.filter((row): boolean => { 29 | return Object.entries(filters).every(([columnName, filterValue]) => { 30 | const columnValue = row[columnName]; 31 | 32 | if (filterValue && typeof columnValue === 'string') { 33 | return columnValue.includes(filterValue); 34 | } 35 | 36 | return true; 37 | }); 38 | }), 39 | [filters, tableData], 40 | ); 41 | 42 | return { 43 | tableDataFiltered, 44 | setFilterValue, 45 | filters, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /client/ui/Table/use-table-sort.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/explicit-function-return-type */ 2 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 3 | import { useCallback, useMemo, useState } from 'react'; 4 | 5 | import type { TableRowAbstract } from './components/TbodyRow'; 6 | 7 | const GREATER = -1; 8 | const LOWER = 1; 9 | const EQUAL = 0; 10 | 11 | export const useTableSort = (tableData?: T[]) => { 12 | const [sort, setSort] = useState(); 13 | const [sortReversed, setSortReversed] = useState(false); 14 | 15 | const onSortChange = useCallback( 16 | (sortName: string) => { 17 | if (sort === sortName) { 18 | if (sortReversed) { 19 | setSort(undefined); 20 | } else { 21 | setSortReversed((v) => !v); 22 | } 23 | } else { 24 | setSortReversed(false); 25 | setSort(sortName); 26 | } 27 | }, 28 | [sort, sortReversed], 29 | ); 30 | 31 | const tableDataSorted = useMemo(() => { 32 | let tableDataSort: T[] | undefined = undefined; 33 | 34 | if (sort !== undefined && tableData) { 35 | // Mutated 36 | tableDataSort = [...tableData].sort((depA, depB): number => { 37 | const valueA = depA[sort] ?? undefined; 38 | const valueB = depB[sort] ?? undefined; 39 | if ( 40 | (typeof valueA === 'string' && typeof valueB === 'string') || 41 | (typeof valueA === 'number' && typeof valueB === 'number') 42 | ) { 43 | if (valueA > valueB) { 44 | return sortReversed ? LOWER : GREATER; 45 | } 46 | if (valueA < valueB) { 47 | return sortReversed ? GREATER : LOWER; 48 | } 49 | return EQUAL; 50 | } 51 | if (valueA !== undefined && valueB === undefined) { 52 | return sortReversed ? LOWER : GREATER; 53 | } 54 | if (valueA === undefined && valueB !== undefined) { 55 | return sortReversed ? GREATER : LOWER; 56 | } 57 | 58 | return EQUAL; 59 | }); 60 | } else if (tableData) { 61 | // Mutated 62 | const regular = tableData.filter((dep) => !dep.name.startsWith('@types')); 63 | 64 | const types = tableData.filter((dep) => dep.name.startsWith('@types')); 65 | 66 | // eslint-disable-next-line unicorn/no-array-reduce 67 | const typesNotAssigned = types.reduce((notAssigned, typeDep) => { 68 | const index = regular.findIndex( 69 | (dep) => dep.name === typeDep.name.split('@types/')[1], 70 | ); 71 | const rgl = regular[index]; 72 | if (rgl) { 73 | rgl.hideBottomBorder = true; 74 | regular.splice(index + 1, 0, { ...typeDep, drawFolder: true }); 75 | } else { 76 | notAssigned.push(typeDep); 77 | } 78 | return notAssigned; 79 | }, [] as T[]); 80 | 81 | tableDataSort = [...regular, ...typesNotAssigned]; 82 | } 83 | 84 | return tableDataSort; 85 | }, [sort, sortReversed, tableData]); 86 | 87 | return { 88 | onSortChange, 89 | sort, 90 | sortReversed, 91 | tableDataSorted, 92 | }; 93 | }; 94 | -------------------------------------------------------------------------------- /client/ui/hooks/use-click-outside.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react'; 2 | import { useEffect, useRef } from 'react'; 3 | 4 | export const useClickOutsideRef = ( 5 | onClickOutside: () => void, 6 | ): RefObject => { 7 | const ref = useRef(null); 8 | 9 | useEffect(() => { 10 | const onHandleClick = ({ target }: MouseEvent): void => { 11 | if (ref.current && target && !ref.current.contains(target as Node)) { 12 | onClickOutside(); 13 | } 14 | }; 15 | 16 | window.addEventListener('click', onHandleClick, true); 17 | 18 | return (): void => { 19 | window.removeEventListener('click', onHandleClick, true); 20 | }; 21 | }, [ref, onClickOutside]); 22 | 23 | return ref; 24 | }; 25 | -------------------------------------------------------------------------------- /client/ui/hooks/use-interval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export const useInterval = (callback: () => void, delay: number): void => { 4 | const savedCallback = useRef<() => void>(); 5 | 6 | useEffect(() => { 7 | savedCallback.current = callback; 8 | }, [callback]); 9 | 10 | useEffect(() => { 11 | const tick = (): void => { 12 | savedCallback.current?.(); 13 | }; 14 | const id = setInterval(tick, delay); 15 | return (): void => clearInterval(id); 16 | }, [delay]); 17 | }; 18 | -------------------------------------------------------------------------------- /client/ui/hooks/use-toggle.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | interface Hook { 4 | isOpen: boolean; 5 | onToggleIsOpen: () => void; 6 | onClose: () => void; 7 | } 8 | 9 | export const useToggle = (): Hook => { 10 | const [isOpen, setIsOpen] = useState(false); 11 | 12 | const onToggleIsOpen = useCallback(() => { 13 | setIsOpen((previousIsOpen) => !previousIsOpen); 14 | }, []); 15 | const onClose = useCallback(() => { 16 | setIsOpen(false); 17 | }, []); 18 | 19 | return { isOpen, onToggleIsOpen, onClose }; 20 | }; 21 | -------------------------------------------------------------------------------- /client/utils.ts: -------------------------------------------------------------------------------- 1 | export const ZERO = 0; 2 | 3 | export const getNormalizedRequiredVersion = ( 4 | required?: string | null, 5 | ): string | undefined => { 6 | if (required === null || required === undefined) { 7 | return undefined; 8 | } 9 | 10 | const normalized = /\d.+/u.exec(required); 11 | 12 | return normalized ? normalized[ZERO] : undefined; 13 | }; 14 | 15 | export const normalizeRepositoryLink = (link: string): string | undefined => 16 | link 17 | .replace('git+', '') 18 | .replace('git://', 'https://') 19 | .replace('ssh://', 'https://') 20 | .replace('.git', '') 21 | .replace('git@', '') 22 | .replace(/#.+/, ''); 23 | -------------------------------------------------------------------------------- /client/xcache.ts: -------------------------------------------------------------------------------- 1 | export const xCacheId = `${Date.now()}`; 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // running npm-gui development version 4 | const { start } = require('./dist/server'); 5 | 6 | const [processArguments] = process.argv.slice(2); 7 | let host = null; 8 | let port = null; 9 | 10 | if (processArguments) { 11 | [host, port] = processArguments.split(':'); 12 | } 13 | 14 | start(host || 'localhost', port || 13377, true); 15 | -------------------------------------------------------------------------------- /install-correct-pnpm-version.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | const { execSync } = require('child_process'); 3 | 4 | const version = /v(?\d+)/.exec(process.version); 5 | console.log('installing:', version, +version.groups.major, process.version); 6 | if (+version.groups.major > 14) { 7 | console.log(execSync('npm install -g pnpm').toString()); 8 | } else if (+version.groups.major > 12) { 9 | console.log(execSync('npm install -g pnpm@6').toString()); 10 | } else { 11 | console.log(execSync('npm install -g pnpm@5').toString()); 12 | } 13 | console.log('pnpm installed:', execSync('pnpm -v').toString()); 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "npm-gui", 3 | "version": "4.0.0-beta", 4 | "description": "A graphical tool for managing javascript project dependencies.", 5 | "keywords": [ 6 | "gui", 7 | "npm", 8 | "yarn", 9 | "pnpm", 10 | "view", 11 | "client", 12 | "dependencies", 13 | "node_modules", 14 | "packages", 15 | "installer", 16 | "manager" 17 | ], 18 | "main": "index.js", 19 | "module": "dist/module.js", 20 | "files": [ 21 | "dist", 22 | "bin" 23 | ], 24 | "scripts": { 25 | "prepack": "npm run build", 26 | "dev:server": "ts-node-dev --files server/development.ts", 27 | "dev:client": "parcel serve --target=client", 28 | "dev": "npm-run-all -p dev:*", 29 | "build": "rm -rf dist && parcel build --no-source-maps", 30 | "lint:ts": "tsc", 31 | "lint:eslint": "eslint . --ignore-path=.gitignore", 32 | "lint:prettier": "prettier --check . --ignore-path=.gitignore", 33 | "lint": "npm-run-all lint:* -p", 34 | "test": "jest", 35 | "ci:test": "env NODE_TEST=true npm run test" 36 | }, 37 | "repository": { 38 | "type": "git", 39 | "url": "git+https://github.com/q-nick/npm-gui.git" 40 | }, 41 | "author": "Paweł Stefański", 42 | "license": "MIT", 43 | "bugs": { 44 | "url": "https://github.com/q-nick/npm-gui/issues" 45 | }, 46 | "bin": { 47 | "npm-gui": "index.js" 48 | }, 49 | "homepage": "https://npm-gui.nullapps.dev", 50 | "devDependencies": { 51 | "@tanstack/query-sync-storage-persister": "^4.22.4", 52 | "@tanstack/react-query": "^4.23.0", 53 | "@tanstack/react-query-persist-client": "^4.23.0", 54 | "@types/fs-extra": "v9.0.13", 55 | "@types/jest": "^27.5.2", 56 | "@types/node": "v11.15.54", 57 | "@types/react": "^18.0.27", 58 | "@types/react-dom": "^18.0.10", 59 | "@types/react-router-dom": "^5.3.3", 60 | "@types/styled-components": "^5.1.26", 61 | "@types/supertest": "^2.0.12", 62 | "eslint-config-turbocharge": "^0.1.8", 63 | "fs-extra": "v10.1.0", 64 | "jest": "^27.5.1", 65 | "jest-extended": "^1.2.1", 66 | "npm-run-all": "^4.1.5", 67 | "open-iconic": "^1.1.1", 68 | "parcel": "^2.8.3", 69 | "process": "^0.11.10", 70 | "react": "^18.2.0", 71 | "react-dom": "^18.2.0", 72 | "react-is": "^18.2.0", 73 | "react-router-dom": "^5.3.4", 74 | "styled-components": "^5.3.6", 75 | "supertest": "^6.3.3", 76 | "ts-jest": "^27.1.5", 77 | "ts-node-dev": "^2.0.0", 78 | "typescript": "^4.9.4", 79 | "use-between": "^1.3.5" 80 | }, 81 | "targets": { 82 | "main": false, 83 | "client": { 84 | "source": "client/index.html", 85 | "context": "browser", 86 | "distDir": "dist" 87 | }, 88 | "server": { 89 | "source": "server/index.ts", 90 | "context": "node", 91 | "outputFormat": "commonjs", 92 | "distDir": "dist" 93 | } 94 | }, 95 | "dependencies": { 96 | "open": "^8.4.0" 97 | }, 98 | "engines": { 99 | "node": ">= 11.0.0", 100 | "npm": ">= 6.0.0" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "turbocharge/node" 4 | } 5 | -------------------------------------------------------------------------------- /server/actions/available-managers/available-managers.ts: -------------------------------------------------------------------------------- 1 | import type { AvailableManagerResponse } from '../../types/global.types'; 2 | import type { ResponserFunction } from '../../types/new-server.types'; 3 | import { executeCommandSimple } from '../execute-command'; 4 | 5 | export const availableManagers: ResponserFunction< 6 | { path?: string }, 7 | unknown, 8 | AvailableManagerResponse 9 | > = async () => { 10 | let npm = true; 11 | let yarn = true; 12 | let pnpm = true; 13 | 14 | try { 15 | await executeCommandSimple(undefined, 'npm --version'); 16 | } catch { 17 | npm = false; 18 | } 19 | 20 | try { 21 | await executeCommandSimple(undefined, 'yarn --version'); 22 | } catch { 23 | yarn = false; 24 | } 25 | 26 | try { 27 | await executeCommandSimple(undefined, 'pnpm --version'); 28 | } catch { 29 | pnpm = false; 30 | } 31 | 32 | return { 33 | npm, 34 | pnpm, 35 | yarn, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /server/actions/dependencies/add/add-global-dependencies.ts: -------------------------------------------------------------------------------- 1 | import type { Installed, Outdated } from '../../../types/commands.types'; 2 | import type { DependencyInstalled } from '../../../types/dependency.types'; 3 | import type { ResponserFunction } from '../../../types/new-server.types'; 4 | import { updateInCache } from '../../../utils/cache'; 5 | import { 6 | getInstalledVersion, 7 | getLatestVersion, 8 | } from '../../../utils/map-dependencies'; 9 | import { 10 | executeCommand, 11 | executeCommandJSONWithFallback, 12 | } from '../../execute-command'; 13 | 14 | const addGlobalNpmDependency = async ({ 15 | name, 16 | version, 17 | }: { 18 | name: string; 19 | version: string; 20 | }): Promise => { 21 | // add 22 | await executeCommand(undefined, `npm install ${name}@${version || ''} -g`); 23 | 24 | // get package info 25 | const { dependencies: installedInfo } = 26 | await executeCommandJSONWithFallback( 27 | undefined, 28 | `npm ls ${name} --depth=0 -g --json`, 29 | ); 30 | 31 | const outdatedInfo = await executeCommandJSONWithFallback( 32 | undefined, 33 | `npm outdated ${name} -g --json`, 34 | ); 35 | 36 | const installed = getInstalledVersion( 37 | installedInfo ? installedInfo[name] : undefined, 38 | ); 39 | 40 | return { 41 | manager: 'npm', 42 | name, 43 | type: 'global', 44 | installed, 45 | latest: getLatestVersion(installed, null, outdatedInfo[name]), 46 | }; 47 | }; 48 | 49 | // eslint-disable-next-line @typescript-eslint/no-type-alias 50 | type RequestBody = [{ name: string; version: string }]; 51 | 52 | export const addGlobalDependencies: ResponserFunction = async ({ 53 | body, 54 | extraParams: { xCacheId }, 55 | }) => { 56 | const dependency = await addGlobalNpmDependency(body[0]); 57 | updateInCache(`${xCacheId}global`, dependency); 58 | 59 | return {}; 60 | }; 61 | -------------------------------------------------------------------------------- /server/actions/dependencies/delete/delete-global-dependencies.ts: -------------------------------------------------------------------------------- 1 | import type { ResponserFunction } from '../../../types/new-server.types'; 2 | import { spliceFromCache } from '../../../utils/cache'; 3 | import { executeCommand } from '../../execute-command'; 4 | 5 | const deleteGlobalNpmDependency = async ( 6 | dependencyName: string, 7 | ): Promise => { 8 | await executeCommand(undefined, `npm uninstall ${dependencyName} -g`); 9 | }; 10 | 11 | interface Parameters { 12 | dependencyName: string; 13 | } 14 | 15 | export const deleteGlobalDependency: ResponserFunction< 16 | unknown, 17 | Parameters 18 | > = async ({ params: { dependencyName }, extraParams: { xCacheId } }) => { 19 | await deleteGlobalNpmDependency(dependencyName); 20 | 21 | spliceFromCache(`${xCacheId}global`, dependencyName); 22 | 23 | return {}; 24 | }; 25 | -------------------------------------------------------------------------------- /server/actions/dependencies/delete/delete-project-dependencies.ts: -------------------------------------------------------------------------------- 1 | import type { Basic, Type } from '../../../types/dependency.types'; 2 | import type { ResponserFunction } from '../../../types/new-server.types'; 3 | import { spliceFromCache } from '../../../utils/cache'; 4 | import { executeCommandSimple } from '../../execute-command'; 5 | 6 | const commandTypeFlag = { 7 | prod: '-S', 8 | dev: '-D', 9 | global: '-g', 10 | extraneous: '', 11 | }; 12 | 13 | const deleteNpmDependencies = async ( 14 | projectPath: string | undefined, 15 | dependencies: Basic[], 16 | type: Type, 17 | ): Promise => { 18 | // delete 19 | await executeCommandSimple( 20 | projectPath, 21 | `npm uninstall ${dependencies.map((d) => d.name).join(' ')} ${ 22 | commandTypeFlag[type] 23 | }`, 24 | ); 25 | }; 26 | 27 | const deletePnpmDependencies = async ( 28 | projectPath: string | undefined, 29 | dependencies: Basic[], 30 | ): Promise => { 31 | // delete 32 | try { 33 | await executeCommandSimple( 34 | projectPath, 35 | `pnpm uninstall ${dependencies.map((d) => d.name).join(' ')}`, 36 | ); 37 | } catch (error: unknown) { 38 | // we are caching error it's unimportant in yarn 39 | if (!process.env['NODE_TEST']) { 40 | // eslint-disable-next-line no-console 41 | console.log(error); 42 | } 43 | } 44 | }; 45 | 46 | const deleteYarnDependencies = async ( 47 | projectPath: string | undefined, 48 | dependencies: Basic[], 49 | ): Promise => { 50 | // delete 51 | try { 52 | await executeCommandSimple( 53 | projectPath, 54 | `yarn remove ${dependencies.map((d) => d.name).join(' ')}`, 55 | ); 56 | } catch (error: unknown) { 57 | // we are caching error it's unimportant in yarn 58 | if (!process.env['NODE_TEST']) { 59 | // eslint-disable-next-line no-console 60 | console.log(error); 61 | } 62 | } 63 | }; 64 | 65 | interface Parameters { 66 | type: Type; 67 | dependencyName: string; 68 | } 69 | 70 | export const deleteDependencies: ResponserFunction< 71 | { name: string }[], 72 | Parameters 73 | > = async ({ 74 | params: { type }, 75 | extraParams: { projectPathDecoded, manager, xCacheId }, 76 | body, 77 | }) => { 78 | if (manager === 'yarn') { 79 | await deleteYarnDependencies(projectPathDecoded, body); 80 | } else if (manager === 'pnpm') { 81 | await deletePnpmDependencies(projectPathDecoded, body); 82 | } else { 83 | await deleteNpmDependencies(projectPathDecoded, body, type); 84 | } 85 | 86 | for (const dependency of body) { 87 | spliceFromCache(xCacheId + manager + projectPathDecoded, dependency.name); 88 | } 89 | 90 | return {}; 91 | }; 92 | -------------------------------------------------------------------------------- /server/actions/dependencies/extras/dependency-details.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-array-callback-reference */ 2 | /* eslint-disable no-await-in-loop */ 3 | import type { Details } from '../../../types/commands.types'; 4 | import type { BundleDetails, Manager } from '../../../types/dependency.types'; 5 | import type { ResponserFunction } from '../../../types/new-server.types'; 6 | import { notEmpty } from '../../../utils/utils'; 7 | import { executeCommandJSONWithFallback } from '../../execute-command'; 8 | import { getChunks } from './utils'; 9 | 10 | const cache: Record = {}; 11 | 12 | interface Parameters { 13 | dependenciesNameVersion: string; 14 | manager: string; 15 | } 16 | 17 | const extractNameFromDependencyString = ( 18 | dependencyNameVersion: string, 19 | ): string => { 20 | return dependencyNameVersion.slice( 21 | 0, 22 | Math.max(0, dependencyNameVersion.lastIndexOf('@')), 23 | ); 24 | }; 25 | 26 | const extractVersionFromDependencyString = ( 27 | dependencyNameVersion: string, 28 | ): string => { 29 | return dependencyNameVersion.slice( 30 | Math.max(0, dependencyNameVersion.lastIndexOf('@') + 1), 31 | ); 32 | }; 33 | 34 | const getDependencyDetailsCross = async ( 35 | dependencyNameVersion: string, 36 | manager: Manager, 37 | ): Promise => { 38 | const bundleInfoCached = cache[`${manager}-${dependencyNameVersion}`]; 39 | 40 | if (bundleInfoCached) { 41 | return bundleInfoCached; 42 | } 43 | 44 | const details = await executeCommandJSONWithFallback< 45 | Details | { data: Details } 46 | >(undefined, `${manager} info ${dependencyNameVersion} --json`); 47 | 48 | // yarn has different structure 49 | const detailsData: Details = 50 | manager === 'yarn' ? (details as any).data : details; 51 | const name = extractNameFromDependencyString(dependencyNameVersion); 52 | const version = extractVersionFromDependencyString(dependencyNameVersion); 53 | 54 | // eslint-disable-next-line require-atomic-updates 55 | cache[`${manager}-${dependencyNameVersion}`] = { 56 | name, 57 | version, 58 | versions: detailsData.versions, 59 | homepage: detailsData.homepage, 60 | repository: detailsData.repository?.url, 61 | size: +detailsData.dist.unpackedSize, 62 | time: detailsData.time, 63 | updated: detailsData.time.modified, 64 | created: detailsData.time.created, 65 | }; 66 | 67 | return { 68 | name, 69 | version, 70 | versions: detailsData.versions, 71 | homepage: detailsData.homepage, 72 | repository: detailsData.repository?.url, 73 | size: +detailsData.dist.unpackedSize, 74 | time: detailsData.time, 75 | updated: detailsData.time.modified, 76 | created: detailsData.time.created, 77 | }; 78 | }; 79 | 80 | export const getDependenciesDetails: ResponserFunction< 81 | unknown, 82 | Parameters, 83 | BundleDetails[] 84 | > = async ({ params: { dependenciesNameVersion, manager } }) => { 85 | const chunks = getChunks(dependenciesNameVersion.split(',')); 86 | try { 87 | const allDetails: BundleDetails[] = []; 88 | 89 | for (const chunk of chunks) { 90 | const chunkDetails = await Promise.all( 91 | chunk.map((item) => 92 | getDependencyDetailsCross(item, manager as Manager), 93 | ), 94 | ); 95 | 96 | allDetails.push(...chunkDetails.filter(notEmpty)); 97 | } 98 | 99 | return allDetails; 100 | } catch (error) { 101 | // eslint-disable-next-line no-console 102 | console.error(error); 103 | return []; 104 | } 105 | }; 106 | -------------------------------------------------------------------------------- /server/actions/dependencies/extras/dependency-score.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/no-array-callback-reference */ 2 | /* eslint-disable no-await-in-loop */ 3 | import type { BundleScore } from '../../../types/dependency.types'; 4 | import type { ResponserFunction } from '../../../types/new-server.types'; 5 | import { requestGET } from '../../../utils/request-with-promise'; 6 | import { notEmpty } from '../../../utils/utils'; 7 | import { getChunks } from './utils'; 8 | 9 | const cache: Record = {}; 10 | 11 | interface Parameters { 12 | dependenciesName: string; 13 | } 14 | 15 | const getDependenciesScoreValue = async ( 16 | dependencyName: string, 17 | ): Promise => { 18 | const bundleInfoCached = cache[dependencyName]; 19 | 20 | if (bundleInfoCached) { 21 | return bundleInfoCached; 22 | } 23 | 24 | try { 25 | const response = await requestGET( 26 | 'snyk.io', 27 | `/advisor/npm-package/${dependencyName}/badge.svg`, 28 | ); 29 | const score = response.match(/>(?\d+)\//)?.groups?.['score']; 30 | 31 | if (score) { 32 | // eslint-disable-next-line require-atomic-updates 33 | cache[dependencyName] = { name: dependencyName, score: +score }; 34 | return cache[dependencyName]; 35 | } 36 | return undefined; 37 | } catch (error) { 38 | // eslint-disable-next-line no-console 39 | console.error(error); 40 | return undefined; 41 | } 42 | }; 43 | 44 | export const getDependenciesScore: ResponserFunction< 45 | unknown, 46 | Parameters, 47 | BundleScore[] 48 | > = async ({ params: { dependenciesName } }) => { 49 | const chunks = getChunks(dependenciesName.split(','), 5); 50 | 51 | try { 52 | const allScore: BundleScore[] = []; 53 | 54 | for (const chunk of chunks) { 55 | const chunkScore = await Promise.all( 56 | chunk.map((dependencyName) => 57 | getDependenciesScoreValue(dependencyName), 58 | ), 59 | ); 60 | 61 | allScore.push(...chunkScore.filter(notEmpty)); 62 | } 63 | 64 | return allScore; 65 | } catch { 66 | return []; 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /server/actions/dependencies/extras/utils.ts: -------------------------------------------------------------------------------- 1 | export const getChunks = (array: T[], chunkSize = 10): T[][] => { 2 | const chunks: T[][] = []; 3 | 4 | for (let index = 0; index < array.length; index += chunkSize) { 5 | const chunk = array.slice(index, index + chunkSize); 6 | // do whatever 7 | chunks.push(chunk); 8 | } 9 | 10 | return chunks; 11 | }; 12 | -------------------------------------------------------------------------------- /server/actions/dependencies/get/get-global-dependencies.ts: -------------------------------------------------------------------------------- 1 | import type { Installed, Outdated } from '../../../types/commands.types'; 2 | import type { 3 | DependencyBase, 4 | DependencyInstalled, 5 | } from '../../../types/dependency.types'; 6 | import type { ResponserFunction } from '../../../types/new-server.types'; 7 | import { getFromCache, putToCache } from '../../../utils/cache'; 8 | import { 9 | getInstalledVersion, 10 | getLatestVersion, 11 | } from '../../../utils/map-dependencies'; 12 | import { executeCommandJSONWithFallback } from '../../execute-command'; 13 | 14 | const getGlobalNpmDependencies = async (): Promise => { 15 | const { dependencies: installedInfo } = 16 | await executeCommandJSONWithFallback( 17 | undefined, 18 | 'npm ls -g --depth=0 --json', 19 | ); 20 | if (!installedInfo) { 21 | return []; 22 | } 23 | 24 | const outdatedInfo = await executeCommandJSONWithFallback( 25 | undefined, 26 | 'npm outdated -g --json', 27 | ); 28 | 29 | return Object.keys(installedInfo).map( 30 | (name): DependencyInstalled => ({ 31 | manager: 'npm', 32 | name, 33 | type: 'global', 34 | installed: getInstalledVersion(installedInfo[name]), 35 | latest: getLatestVersion( 36 | getInstalledVersion(installedInfo[name]), 37 | null, 38 | outdatedInfo[name], 39 | ), 40 | }), 41 | ); 42 | }; 43 | 44 | const getGlobalNpmDependenciesSimple = async (): Promise => { 45 | const { dependencies: installedInfo } = 46 | await executeCommandJSONWithFallback( 47 | undefined, 48 | 'npm ls -g --depth=0 --json', 49 | ); 50 | if (!installedInfo) { 51 | return []; 52 | } 53 | 54 | return Object.keys(installedInfo).map((name) => ({ 55 | manager: 'npm', 56 | name, 57 | type: 'global', 58 | installed: getInstalledVersion(installedInfo[name]), 59 | })); 60 | }; 61 | 62 | export const getGlobalDependencies: ResponserFunction = async ({ 63 | extraParams: { xCacheId }, 64 | }) => { 65 | const cache = getFromCache(`${xCacheId}global`); 66 | if (cache) { 67 | return cache; 68 | } 69 | 70 | const npmDependencies = await getGlobalNpmDependencies(); 71 | putToCache(`${xCacheId}global`, npmDependencies); 72 | // TODO cache-id 73 | 74 | return npmDependencies; 75 | }; 76 | 77 | export const getGlobalDependenciesSimple: ResponserFunction = async () => { 78 | const npmDependencies = await getGlobalNpmDependenciesSimple(); 79 | 80 | return npmDependencies; 81 | }; 82 | -------------------------------------------------------------------------------- /server/actions/dependencies/install/install-project-dependencies.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, unlinkSync } from 'fs'; 2 | import path from 'path'; 3 | 4 | import type { Manager } from '../../../types/dependency.types'; 5 | import type { ResponserFunction } from '../../../types/new-server.types'; 6 | import { clearCache } from '../../../utils/cache'; 7 | import { deleteFolderRecursive } from '../../../utils/delete-folder-resursive'; 8 | import { executeCommandSimple } from '../../execute-command'; 9 | 10 | const clearManagerFiles = (projectPath: string): void => { 11 | if (existsSync(`${path.normalize(projectPath)}/node_modules`)) { 12 | deleteFolderRecursive(`${path.normalize(projectPath)}/node_modules`); 13 | } 14 | 15 | for (const fileName of ['yarn.lock', 'package-lock.json', 'pnpm-lock.yaml']) { 16 | if (existsSync(`${path.normalize(projectPath)}/${fileName}`)) { 17 | unlinkSync(`${path.normalize(projectPath)}/${fileName}`); 18 | } 19 | } 20 | }; 21 | 22 | export const installDependenciesForceManager: ResponserFunction< 23 | unknown, 24 | { forceManager: Manager } 25 | > = async ({ 26 | params: { forceManager }, 27 | extraParams: { projectPathDecoded, xCacheId }, 28 | }) => { 29 | clearManagerFiles(projectPathDecoded); 30 | 31 | await executeCommandSimple(projectPathDecoded, `${forceManager} install`); 32 | 33 | clearCache(xCacheId + forceManager + projectPathDecoded); 34 | 35 | return {}; 36 | }; 37 | 38 | export const installDependencies: ResponserFunction = async ({ 39 | extraParams: { projectPathDecoded, manager = 'npm', xCacheId }, 40 | }) => { 41 | await executeCommandSimple(projectPathDecoded, `${manager} install`); 42 | 43 | clearCache(xCacheId + manager + projectPathDecoded); 44 | 45 | return {}; 46 | }; 47 | -------------------------------------------------------------------------------- /server/actions/execute-command.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from '../utils/simple-cross-spawn'; 2 | import { ZERO } from '../utils/utils'; 3 | 4 | export const executeCommand = ( 5 | cwd: string | undefined, 6 | wholeCommand: string, 7 | ): Promise<{ stdout: string; stderr: string }> => { 8 | console.log(`Command: ${wholeCommand}, started`); 9 | return new Promise((resolve, reject) => { 10 | // spawn process 11 | const commandArguments = wholeCommand.split(' '); 12 | const command = commandArguments.shift(); 13 | 14 | if (!command) { 15 | reject(new Error('command not passed')); 16 | } else { 17 | const spawned = spawn(command, commandArguments, { 18 | cwd, 19 | detached: false, 20 | }); 21 | 22 | // wait for stdout, stderr 23 | let stdout = ''; 24 | spawned.stdout?.on('data', (data: Buffer) => { 25 | stdout += data.toString(); 26 | }); 27 | 28 | let stderr = ''; 29 | spawned.stderr?.on('data', (data: Buffer) => { 30 | stderr += data.toString(); 31 | }); 32 | 33 | // wait for finish and resolve 34 | spawned.on('close', (exitStatus: number) => { 35 | if (exitStatus === ZERO) { 36 | resolve({ 37 | stdout, 38 | stderr, 39 | }); 40 | } else { 41 | reject(stdout + stderr); 42 | } 43 | }); 44 | 45 | // if error 46 | spawned.on('error', () => { 47 | reject(stderr); 48 | }); 49 | } 50 | }); 51 | }; 52 | 53 | export const executeCommandSimple = async ( 54 | cwd: string | undefined, 55 | wholeCommand: string, 56 | ): Promise => { 57 | const { stdout } = await executeCommand(cwd, wholeCommand); 58 | return stdout; 59 | }; 60 | 61 | // eslint-disable-next-line func-style 62 | export async function executeCommandJSONWithFallback( 63 | cwd: string | undefined, 64 | wholeCommand: string, 65 | ): Promise { 66 | try { 67 | const { stdout } = await executeCommand(cwd, wholeCommand); 68 | if (!process.env['NODE_TEST']) { 69 | console.log('OK:', wholeCommand); 70 | } 71 | return stdout ? (JSON.parse(stdout) as T) : ({} as T); 72 | } catch (error: unknown) { 73 | if (!process.env['NODE_TEST']) { 74 | console.log('ERROR:', wholeCommand, '\n', error); 75 | } 76 | return JSON.parse( 77 | (error as string).replace(/(\n{[\S\s]+)?npm ERR[\S\s]+/gm, ''), 78 | ) as T; 79 | } 80 | } 81 | 82 | // eslint-disable-next-line func-style, max-statements 83 | export async function executeCommandJSONWithFallbackYarn( 84 | cwd: string | undefined, 85 | wholeCommand: string, 86 | ): Promise { 87 | try { 88 | const { stdout, stderr } = await executeCommand(cwd, wholeCommand); 89 | if (!process.env['NODE_TEST']) { 90 | console.log('OK:', wholeCommand); 91 | } 92 | const JSONs = (stdout + stderr) 93 | .trim() 94 | .split('\n') 95 | .filter((x) => x) 96 | .map((r) => JSON.parse(r)); 97 | const table = JSONs.find((x) => 'type' in x && x.type === 'table') as 98 | | T 99 | | undefined; 100 | if (table) { 101 | return table; 102 | } 103 | 104 | const anyError = JSONs.find((x) => 'type' in x && x.type === 'error') as 105 | | T 106 | | undefined; 107 | if (anyError) { 108 | return anyError; 109 | } 110 | } catch (error: unknown) { 111 | if (!process.env['NODE_TEST']) { 112 | console.log('ERROR:', wholeCommand, '\n', error); 113 | } 114 | 115 | if (typeof error === 'string') { 116 | const JSONS = error 117 | .trim() 118 | .split('\n') 119 | .filter((x) => x) 120 | .map((r) => JSON.parse(r)); 121 | const table = JSONS.find((x) => 'type' in x && x.type === 'table') as 122 | | T 123 | | undefined; 124 | if (table) { 125 | return table; 126 | } 127 | 128 | const anyError = JSONS.find((x) => 'type' in x && x.type === 'error') as 129 | | T 130 | | undefined; 131 | if (anyError) { 132 | return anyError; 133 | } 134 | } 135 | 136 | return JSON.parse(error as string) as T; 137 | } 138 | 139 | return undefined; 140 | } 141 | -------------------------------------------------------------------------------- /server/actions/explorer/explorer.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, lstatSync, readdirSync } from 'fs'; 2 | import path from 'path'; 3 | 4 | import { decodePath } from '../../middlewares/project-path-and-manager.middleware'; 5 | import type { 6 | ExplorerRequest, 7 | ExplorerResponse, 8 | } from '../../types/global.types'; 9 | import type { ResponserFunction } from '../../types/new-server.types'; 10 | 11 | export const explorer: ResponserFunction< 12 | unknown, 13 | ExplorerRequest, 14 | ExplorerResponse 15 | > = ({ params }) => { 16 | let normalizedPath = 17 | params.path !== undefined ? path.normalize(decodePath(params.path)) : null; 18 | 19 | let changed = false; 20 | 21 | if (normalizedPath === null || !existsSync(normalizedPath)) { 22 | normalizedPath = process.cwd(); 23 | changed = true; 24 | } 25 | 26 | const ls = readdirSync(normalizedPath).map((name) => ({ 27 | name, 28 | isDirectory: lstatSync(`${normalizedPath}/${name}`).isDirectory(), 29 | isProject: [ 30 | 'package.json', 31 | 'package-lock.json', 32 | 'yarn.lock', 33 | 'pnpm-lock.yaml', 34 | ].includes(name), 35 | })); 36 | 37 | return { 38 | ls, 39 | changed, 40 | path: normalizedPath, 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /server/actions/info/info.ts: -------------------------------------------------------------------------------- 1 | import type { ResponserFunction } from '../../types/new-server.types'; 2 | import { requestGET } from '../../utils/request-with-promise'; 3 | 4 | export const info: ResponserFunction = async ({ 5 | params: { id }, 6 | }) => { 7 | return requestGET('v4.npm-gui.nullapps.dev', `/info.html?${id}`); 8 | }; 9 | -------------------------------------------------------------------------------- /server/actions/pnpm-utils.ts: -------------------------------------------------------------------------------- 1 | import { executeCommandSimple } from './execute-command'; 2 | 3 | const ansiRegex = (): RegExp => { 4 | const pattern = [ 5 | '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)', 6 | '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', 7 | ].join('|'); 8 | 9 | return new RegExp(pattern, 'g'); 10 | }; 11 | 12 | export const executePnpmOutdated = async ( 13 | outdatedInfo: any, 14 | projectPath: string, 15 | compatible = false, 16 | ): Promise => { 17 | try { 18 | await executeCommandSimple( 19 | projectPath, 20 | `pnpm outdated ${compatible ? '--compatible' : ''} --no-table`, 21 | ); 22 | } catch (error: unknown) { 23 | if (typeof error === 'string') { 24 | const rows = error.replace(ansiRegex(), '').split('\n'); 25 | let name = ''; 26 | for (const row of rows) { 27 | const rowResult = /=>.([\d.]+)/.exec(row); 28 | if (rowResult) { 29 | outdatedInfo[name] = { 30 | ...outdatedInfo[name], 31 | [compatible ? 'wanted' : 'latest']: rowResult[1], 32 | }; 33 | } else { 34 | name = row.replace('(dev)', '').trim(); 35 | } 36 | } 37 | } 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /server/actions/search/search.ts: -------------------------------------------------------------------------------- 1 | import type { SearchResponse } from '../../types/global.types'; 2 | import type { ResponserFunction } from '../../types/new-server.types'; 3 | import { parseJSON } from '../../utils/parse-json'; 4 | import { requestGET } from '../../utils/request-with-promise'; 5 | 6 | interface NPMApiResult { 7 | results: { 8 | package: { 9 | name: string; 10 | description: string; 11 | version: string; 12 | date: string; 13 | links: { 14 | npm: string; 15 | homepage: string; 16 | repository: string; 17 | bugs: string; 18 | }; 19 | }; 20 | score: { 21 | final: number; 22 | }; 23 | }[]; 24 | } 25 | 26 | export const search: ResponserFunction< 27 | { query: string }, 28 | unknown, 29 | SearchResponse 30 | > = async ({ body: { query } }) => { 31 | const response = await requestGET( 32 | 'api.npms.io', 33 | `/v2/search?from=0&size=25&q=${query}`, 34 | ); 35 | 36 | const parsed = parseJSON(response); 37 | if (!parsed) { 38 | throw new Error('Unable to get package info'); 39 | } 40 | 41 | return parsed.results.map((result) => ({ 42 | name: result.package.name, 43 | version: result.package.version, 44 | score: result.score.final, 45 | updated: result.package.date, 46 | npm: result.package.links.npm, 47 | repository: result.package.links.repository, 48 | homepage: result.package.links.homepage, 49 | description: result.package.description, 50 | })); 51 | }; 52 | -------------------------------------------------------------------------------- /server/actions/yarn-utils.ts: -------------------------------------------------------------------------------- 1 | import type { OutdatedYarn } from '../types/yarn.types'; 2 | 3 | export interface YarnDependenciesVersions { 4 | wanted?: string; 5 | current?: string; 6 | latest?: string; 7 | } 8 | 9 | export const extractVersionFromYarnOutdated = ( 10 | outdatedInfo?: OutdatedYarn, 11 | ): Record => { 12 | if (!outdatedInfo || !outdatedInfo.data) { 13 | return {}; 14 | } 15 | const nameIndex = outdatedInfo.data.head.indexOf('Package'); 16 | const wantedIndex = outdatedInfo.data.head.indexOf('Wanted'); 17 | const latestIndex = outdatedInfo.data.head.indexOf('Latest'); 18 | const currentIndex = outdatedInfo.data.head.indexOf('Current'); 19 | 20 | const dependencies: Record = {}; 21 | 22 | for (const packageArray of outdatedInfo.data.body) { 23 | const name = packageArray[nameIndex]!; 24 | dependencies[name] = { 25 | wanted: packageArray[wantedIndex], 26 | latest: packageArray[latestIndex], 27 | current: packageArray[currentIndex], 28 | }; 29 | } 30 | 31 | return dependencies; 32 | }; 33 | -------------------------------------------------------------------------------- /server/development.ts: -------------------------------------------------------------------------------- 1 | import { start } from './index'; 2 | 3 | // eslint-disable-next-line prefer-destructuring 4 | const hostAndPort = process.argv[2]; 5 | 6 | const [host, port] = hostAndPort?.split(':') ?? ['localhost', '3000']; 7 | 8 | start(host, typeof port === 'string' ? Number.parseInt(port, 10) : undefined); 9 | -------------------------------------------------------------------------------- /server/index.html: -------------------------------------------------------------------------------- 1 | test 2 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/max-dependencies */ 2 | import open from 'open'; 3 | 4 | import { availableManagers } from './actions/available-managers/available-managers'; 5 | import { addGlobalDependencies } from './actions/dependencies/add/add-global-dependencies'; 6 | import { addDependencies } from './actions/dependencies/add/add-project-dependencies'; 7 | import { deleteGlobalDependency } from './actions/dependencies/delete/delete-global-dependencies'; 8 | import { deleteDependencies } from './actions/dependencies/delete/delete-project-dependencies'; 9 | import { getDependenciesDetails } from './actions/dependencies/extras/dependency-details'; 10 | import { getDependenciesScore } from './actions/dependencies/extras/dependency-score'; 11 | import { 12 | getGlobalDependencies, 13 | getGlobalDependenciesSimple, 14 | } from './actions/dependencies/get/get-global-dependencies'; 15 | import { 16 | getAllDependencies, 17 | getAllDependenciesSimple, 18 | } from './actions/dependencies/get/get-project-dependencies'; 19 | import { 20 | installDependencies, 21 | installDependenciesForceManager, 22 | } from './actions/dependencies/install/install-project-dependencies'; 23 | import { explorer } from './actions/explorer/explorer'; 24 | import { info } from './actions/info/info'; 25 | import { search } from './actions/search/search'; 26 | import { projectPathAndManagerMiddleware } from './middlewares/project-path-and-manager.middleware'; 27 | import { Server } from './simple-express'; 28 | 29 | const DEFAULT_PORT = 3000; 30 | const DEFAULT_HOST = 'localhost'; 31 | 32 | export const app = new Server(); 33 | 34 | app.use('/api/project/:projectPath/', projectPathAndManagerMiddleware); 35 | 36 | app.get( 37 | '/api/project/:projectPath/dependencies/simple', 38 | getAllDependenciesSimple, 39 | ); 40 | app.get('/api/project/:projectPath/dependencies/full', getAllDependencies); 41 | app.post( 42 | '/api/project/:projectPath/dependencies/install/:forceManager', 43 | installDependenciesForceManager, 44 | ); 45 | app.post('/api/project/:projectPath/dependencies/install', installDependencies); 46 | app.post('/api/project/:projectPath/dependencies/:type', addDependencies); 47 | app.delete('/api/project/:projectPath/dependencies/:type', deleteDependencies); 48 | 49 | // global routes 50 | app.get('/api/global/dependencies/simple', getGlobalDependenciesSimple); 51 | app.get('/api/global/dependencies/full', getGlobalDependencies); 52 | app.post('/api/global/dependencies', addGlobalDependencies); 53 | app.delete( 54 | '/api/global/dependencies/global/:dependencyName', 55 | deleteGlobalDependency, 56 | ); 57 | 58 | // dependencies extra apis 59 | app.get('/api/score/:dependenciesName', getDependenciesScore); 60 | app.get( 61 | '/api/details/:manager/:dependenciesNameVersion', 62 | getDependenciesDetails, 63 | ); 64 | 65 | // other apis 66 | app.get('/api/explorer/:path', explorer); 67 | app.get('/api/explorer/', explorer); 68 | app.get('/api/available-managers', availableManagers); 69 | app.post('/api/search/:repoName', search); 70 | app.get('/api/info/:id', info); 71 | 72 | /* istanbul ignore next */ 73 | export const start = ( 74 | host = DEFAULT_HOST, 75 | port = DEFAULT_PORT, 76 | openBrowser = false, 77 | ): void => { 78 | app.listen(port, host); 79 | if (openBrowser) { 80 | void open(`http://${host}:${port}`); 81 | } 82 | }; 83 | 84 | // only for parcel? 85 | // eslint-disable-next-line import/no-commonjs 86 | module.exports = { start, app }; 87 | -------------------------------------------------------------------------------- /server/middlewares/project-path-and-manager.middleware.ts: -------------------------------------------------------------------------------- 1 | import { existsSync } from 'fs'; 2 | import path from 'path'; 3 | 4 | import type { MiddlewareFunction } from '../types/new-server.types'; 5 | 6 | export const decodePath = (pathEncoded: string): string => { 7 | return path.normalize(Buffer.from(pathEncoded, 'base64').toString()); 8 | }; 9 | 10 | export const isYarnProject = (projectPath: string): boolean => { 11 | return existsSync(path.join(projectPath, 'yarn.lock')); 12 | }; 13 | 14 | export const isPnpmProject = (projectPath: string): boolean => { 15 | return existsSync(path.join(projectPath, 'pnpm-lock.yaml')); 16 | }; 17 | 18 | export const isNpmProject = (projectPath: string): boolean => { 19 | return existsSync(path.join(projectPath, 'package.json')); 20 | }; 21 | 22 | export const projectPathAndManagerMiddleware: MiddlewareFunction<{ 23 | projectPath: string; 24 | }> = ({ params: { projectPath } }) => { 25 | const projectPathDecoded = decodePath(projectPath); 26 | 27 | const isYarn = isYarnProject(projectPathDecoded); 28 | const isNpm = isNpmProject(projectPathDecoded); 29 | const isPnpm = isPnpmProject(projectPathDecoded); 30 | 31 | if (!isYarn && !isNpm && !isPnpm) { 32 | throw new Error('invalid project structure!'); 33 | } 34 | 35 | // default 36 | let manager = 'npm'; 37 | 38 | if (isPnpm) { 39 | // special 40 | manager = 'pnpm'; 41 | } else if (isYarn) { 42 | manager = 'yarn'; 43 | } 44 | 45 | return { 46 | projectPathDecoded, 47 | manager, 48 | xCache: 'any', 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-turbocharge/node/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "lib": ["es2015"], 6 | "outDir": "../dist" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/types/commands.types.ts: -------------------------------------------------------------------------------- 1 | export interface OutdatedBody { 2 | current: string; 3 | wanted: string; 4 | latest: string; 5 | } 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-type-alias 8 | export type Outdated = Record; 9 | 10 | interface InstalledBodyBase { 11 | required: { 12 | version: string; 13 | }; 14 | peerMissing: boolean; 15 | } 16 | 17 | interface InstalledBodyMissing { 18 | required: string; 19 | missing: boolean; 20 | } 21 | 22 | interface InstalledBodyExtra { 23 | version: string; 24 | extraneous?: boolean; 25 | } 26 | 27 | interface InstalledBodyInvalid { 28 | invalid: string; 29 | problems: string[]; 30 | } 31 | 32 | interface InstalledBodyInvalidOverriden { 33 | overriden: boolean; 34 | } 35 | 36 | export type InstalledBody = 37 | | InstalledBodyBase 38 | | InstalledBodyExtra 39 | | InstalledBodyInvalid 40 | | InstalledBodyInvalidOverriden 41 | | InstalledBodyMissing; 42 | 43 | export interface Installed { 44 | dependencies?: Record; 45 | } 46 | 47 | export interface Details { 48 | repository?: { 49 | type: string; 50 | url: string; 51 | directory: string; 52 | }; 53 | homepage: string; 54 | versions: string[]; 55 | time: { 56 | [key: string]: string; 57 | modified: string; 58 | created: string; 59 | }; 60 | dist: { 61 | unpackedSize: string; 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /server/types/dependency.types.ts: -------------------------------------------------------------------------------- 1 | export type Type = 'dev' | 'extraneous' | 'global' | 'prod'; 2 | export type Manager = 'npm' | 'pnpm' | 'yarn'; 3 | 4 | export interface Basic { 5 | name: string; 6 | version?: string; 7 | type?: Type; 8 | } 9 | 10 | export interface Npm { 11 | name: string; 12 | type: Type; 13 | version?: string; 14 | required?: string; 15 | } 16 | 17 | export interface Version { 18 | wanted: string; 19 | latest: string; 20 | } 21 | 22 | export interface DependencyBase { 23 | [key: string]: unknown; 24 | name: string; 25 | type: Type; 26 | manager: Manager; 27 | required?: string; 28 | } 29 | 30 | export interface BundleScore { 31 | score: number | null; 32 | name: string; 33 | } 34 | 35 | export interface BundleSize { 36 | size: number; 37 | version: string; 38 | gzip: number; 39 | name: string; 40 | repository: string; 41 | } 42 | 43 | export interface BundleDetails { 44 | name: string; 45 | version: string; 46 | size: number; 47 | homepage: string; 48 | repository: string | undefined; 49 | updated: string; 50 | created: string; 51 | versions: string[]; 52 | time: Record; 53 | } 54 | 55 | export interface DependencyInstalled extends DependencyBase { 56 | installed?: string | null; 57 | wanted?: string | null; 58 | latest?: string | null; 59 | } 60 | 61 | export interface DependencyInstalledExtras 62 | extends DependencyInstalled, 63 | Partial>, 64 | Partial> {} 65 | 66 | export interface SearchResult { 67 | description: string; 68 | name: string; 69 | score: number; 70 | url: string; 71 | version: string; 72 | } 73 | -------------------------------------------------------------------------------- /server/types/global.types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-type-alias */ 2 | 3 | export type SearchResponse = { 4 | name: string; 5 | version: string; 6 | score: number; 7 | updated: string; 8 | repository?: string; 9 | homepage?: string; 10 | npm?: string; 11 | description: string; 12 | }[]; 13 | 14 | export interface ExplorerRequest { 15 | path?: string; 16 | } 17 | 18 | export interface FileOrFolder { 19 | name: string; 20 | isDirectory: boolean; 21 | isProject: boolean; 22 | } 23 | 24 | export interface ExplorerResponse { 25 | ls: FileOrFolder[]; 26 | path: string; 27 | changed: boolean; 28 | } 29 | 30 | export interface AvailableManagerResponse { 31 | npm: boolean; 32 | yarn: boolean; 33 | pnpm: boolean; 34 | } 35 | -------------------------------------------------------------------------------- /server/types/new-server.types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-type-alias */ 2 | import type { Manager } from './dependency.types'; 3 | 4 | export type MiddlewareFunction

    = (requestData: { 5 | params: P; 6 | extraParams: Record; 7 | }) => Record; 8 | 9 | export type ResponserFunction< 10 | B = unknown, 11 | P = unknown, 12 | R = unknown, 13 | > = (requestData: { 14 | params: P; 15 | extraParams: { 16 | projectPathDecoded: string; 17 | manager: Manager; 18 | xCacheId: string; 19 | }; 20 | body: B; 21 | }) => Promise | R; 22 | -------------------------------------------------------------------------------- /server/types/pnpm.types.ts: -------------------------------------------------------------------------------- 1 | export interface OutdatedBodyPNPM { 2 | current: string; 3 | wanted: string; 4 | latest: string; 5 | } 6 | 7 | export type OutdatedPNPM = string; 8 | 9 | interface InstalledBodyBase { 10 | version: string; 11 | } 12 | 13 | // interface InstalledBodyMissing { 14 | // required: string; 15 | // missing: boolean; 16 | // } 17 | 18 | // interface InstalledBodyExtra { 19 | // version: string; 20 | // extraneous?: boolean; 21 | // } 22 | 23 | export type InstalledBodyPNPM = InstalledBodyBase; // | InstalledBodyExtra | InstalledBodyMissing; 24 | 25 | export type InstalledPNPM = [ 26 | { 27 | devDependencies?: Record; 28 | dependencies?: Record; 29 | }, 30 | ]; 31 | -------------------------------------------------------------------------------- /server/types/yarn.types.ts: -------------------------------------------------------------------------------- 1 | interface InstalledBase { 2 | data: { 3 | trees: [ 4 | { 5 | name: string; 6 | }, 7 | ]; 8 | }; 9 | } 10 | 11 | export type InstalledYarn = InstalledBase; 12 | 13 | export interface OutdatedYarn { 14 | data?: { 15 | head: string[]; 16 | body: string[][]; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /server/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import type { DependencyInstalled } from '../types/dependency.types'; 2 | import { ONE, ZERO } from './utils'; 3 | 4 | type CacheValue = DependencyInstalled[] | undefined; 5 | 6 | let cache: Record = {}; 7 | 8 | export const getFromCache = (name: string): CacheValue => { 9 | return cache[name]; 10 | }; 11 | 12 | export const putToCache = (name: string, data?: CacheValue): void => { 13 | cache[name] = data; 14 | }; 15 | 16 | export const updateInCache = ( 17 | name: string, 18 | dependency: DependencyInstalled, 19 | ): void => { 20 | const myCache = cache[name]; 21 | if (myCache) { 22 | const indexToUpdate = myCache.findIndex( 23 | (item) => dependency.name === item.name, 24 | ); 25 | 26 | if (indexToUpdate >= ZERO) { 27 | myCache[indexToUpdate] = dependency; 28 | } else { 29 | myCache.push(dependency); 30 | } 31 | } 32 | }; 33 | 34 | export const spliceFromCache = (name: string, dependencyName: string): void => { 35 | const myCache = cache[name]; 36 | 37 | if (myCache) { 38 | const indexToSplice = myCache.findIndex( 39 | (item) => dependencyName === item.name, 40 | ); 41 | 42 | if (indexToSplice >= ZERO) { 43 | myCache.splice(indexToSplice, ONE); 44 | } 45 | } 46 | }; 47 | 48 | export const clearCache = (name?: string): void => { 49 | if (name === undefined) { 50 | cache = {}; 51 | } else { 52 | putToCache(name); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /server/utils/delete-folder-resursive.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, lstatSync, readdirSync, rmdirSync, unlinkSync } from 'fs'; 2 | 3 | export const deleteFolderRecursive = (rmPath: string): void => { 4 | let files = []; 5 | if (existsSync(rmPath)) { 6 | files = readdirSync(rmPath); 7 | for (const [, file] of files.entries()) { 8 | const currentPath = `${rmPath}/${file}`; 9 | if (lstatSync(currentPath).isDirectory()) { 10 | // recurse 11 | deleteFolderRecursive(currentPath); 12 | } else { 13 | // delete file 14 | unlinkSync(currentPath); 15 | } 16 | } 17 | rmdirSync(rmPath); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /server/utils/get-project-package-json.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from 'fs'; 2 | import path from 'path'; 3 | 4 | import type { DependencyBase, Manager } from '../types/dependency.types'; 5 | import { parseJSON } from './parse-json'; 6 | 7 | interface PackageJSON { 8 | dependencies?: Record; 9 | devDependencies?: Record; 10 | } 11 | 12 | export const getProjectPackageJSON = ( 13 | projectPath: string, 14 | ): PackageJSON | null => { 15 | const packageJSONpath = path.join(projectPath, 'package.json'); 16 | if (existsSync(packageJSONpath)) { 17 | return parseJSON( 18 | readFileSync(packageJSONpath, { encoding: 'utf8' }), 19 | ); 20 | } 21 | 22 | return null; 23 | }; 24 | 25 | export const getDependenciesFromPackageJson = ( 26 | projectPath: string, 27 | ): Record => { 28 | const packageJson = getProjectPackageJSON(projectPath); 29 | if (packageJson === null || !('dependencies' in packageJson)) { 30 | return {}; 31 | } 32 | return packageJson.dependencies ?? {}; 33 | }; 34 | 35 | export const getDevelopmentDependenciesFromPackageJson = ( 36 | projectPath: string, 37 | ): Record => { 38 | const packageJson = getProjectPackageJSON(projectPath); 39 | if (packageJson === null || !('devDependencies' in packageJson)) { 40 | return {}; 41 | } 42 | return packageJson.devDependencies ?? {}; 43 | }; 44 | 45 | export const getAllDependenciesFromPackageJsonAsArray = ( 46 | projectPath: string, 47 | manager: Manager, 48 | ): DependencyBase[] => { 49 | const dependencies = getDependenciesFromPackageJson(projectPath); 50 | const devDependencies = 51 | getDevelopmentDependenciesFromPackageJson(projectPath); 52 | 53 | return [ 54 | ...Object.entries(dependencies).map( 55 | ([name, required]): DependencyBase => ({ 56 | manager, 57 | name, 58 | type: 'prod', 59 | required, 60 | }), 61 | ), 62 | ...Object.entries(devDependencies).map( 63 | ([name, required]): DependencyBase => ({ 64 | manager, 65 | name, 66 | type: 'dev', 67 | required, 68 | }), 69 | ), 70 | ]; 71 | }; 72 | 73 | export const getTypeFromPackageJson = ( 74 | projectPath: string, 75 | dependencyName: string, 76 | ): 'dev' | 'extraneous' | 'prod' => { 77 | const packageJson = getProjectPackageJSON(projectPath); 78 | if (packageJson === null) { 79 | console.log('ERROR????'); 80 | return 'extraneous'; 81 | } 82 | 83 | const { dependencies, devDependencies } = packageJson; 84 | 85 | if (dependencies && dependencyName in dependencies) { 86 | return 'prod'; 87 | } 88 | 89 | if (devDependencies && dependencyName in devDependencies) { 90 | return 'dev'; 91 | } 92 | 93 | return 'extraneous'; 94 | }; 95 | 96 | export const getRequiredFromPackageJson = ( 97 | projectPath: string, 98 | dependencyName: string, 99 | ): string | undefined => { 100 | const packageJson = getProjectPackageJSON(projectPath); 101 | if (packageJson === null) { 102 | return undefined; 103 | } 104 | 105 | const { dependencies, devDependencies } = packageJson; 106 | 107 | if (dependencies && dependencyName in dependencies) { 108 | return dependencies[dependencyName]; 109 | } 110 | 111 | if (devDependencies && dependencyName in devDependencies) { 112 | return devDependencies[dependencyName]; 113 | } 114 | 115 | return undefined; 116 | }; 117 | -------------------------------------------------------------------------------- /server/utils/map-dependencies.ts: -------------------------------------------------------------------------------- 1 | import type { InstalledBody } from '../types/commands.types'; 2 | 3 | export const uniqueOrNull = ( 4 | value: string | undefined, 5 | comparision: (string | null | undefined)[], 6 | ): string | null => { 7 | if (value === undefined) { 8 | return null; 9 | } 10 | 11 | return comparision.includes(value) ? null : value; 12 | }; 13 | 14 | // eslint-disable-next-line max-statements 15 | export const getInstalledVersion = ( 16 | installed?: InstalledBody, 17 | ): string | null => { 18 | if (!installed) { 19 | return null; 20 | } 21 | 22 | if ('version' in installed) { 23 | return installed.version; 24 | } 25 | 26 | if ('invalid' in installed) { 27 | return null; 28 | } 29 | 30 | if ('missing' in installed) { 31 | return null; 32 | } 33 | 34 | if ('extraneous' in installed) { 35 | return null; 36 | } 37 | 38 | if (!('required' in installed)) { 39 | return null; 40 | } 41 | 42 | if (typeof installed.required === 'string') { 43 | return null; 44 | } 45 | 46 | // TODO peerMissing ERROR HERE 47 | return installed.required.version; 48 | }; 49 | 50 | export const getWantedVersion = ( 51 | installed: string | null | undefined, 52 | outdated?: { wanted?: string; latest?: string }, 53 | ): string | null => { 54 | if (installed === null || !outdated) { 55 | return null; 56 | } 57 | 58 | return uniqueOrNull(outdated.wanted, [installed]); 59 | }; 60 | 61 | export const getLatestVersion = ( 62 | installed: string | null | undefined, 63 | wanted: string | null | undefined, 64 | outdated?: { wanted?: string; latest?: string }, 65 | ): string | null => { 66 | if (installed === null || !outdated) { 67 | return null; 68 | } 69 | 70 | return uniqueOrNull(outdated.latest, [installed, wanted]); 71 | }; 72 | -------------------------------------------------------------------------------- /server/utils/parse-json.ts: -------------------------------------------------------------------------------- 1 | export const parseJSON = (stringToParse: string): T | null => { 2 | let result = null; 3 | try { 4 | result = JSON.parse(stringToParse) as T; 5 | } catch { 6 | // eslint-disable-next-line no-console 7 | console.error('JSON error', stringToParse, '#'); 8 | return null; 9 | } 10 | 11 | return result; 12 | }; 13 | -------------------------------------------------------------------------------- /server/utils/request-with-promise.ts: -------------------------------------------------------------------------------- 1 | import type { ClientRequestArgs } from 'http'; 2 | import https from 'https'; 3 | 4 | export const requestGET = (hostname: string, path: string): Promise => { 5 | return new Promise((resolve, reject) => { 6 | const options: ClientRequestArgs = { 7 | hostname, 8 | port: 443, 9 | path: encodeURI(path), 10 | method: 'GET', 11 | headers: { 12 | // eslint-disable-next-line @typescript-eslint/naming-convention 13 | 'User-Agent': 'npm-gui', 14 | }, 15 | }; 16 | 17 | const request = https.request(options, (response) => { 18 | let responseData = ''; 19 | 20 | response.on('data', (data) => { 21 | responseData += data.toString(); 22 | }); 23 | response.on('end', () => { 24 | resolve(responseData); 25 | }); 26 | }); 27 | 28 | request.on('error', (error) => { 29 | reject(error); 30 | }); 31 | 32 | request.end(); 33 | }); 34 | }; 35 | -------------------------------------------------------------------------------- /server/utils/simple-cross-spawn.ts: -------------------------------------------------------------------------------- 1 | import type { ChildProcess, SpawnOptionsWithoutStdio } from 'child_process'; 2 | import { spawn as cpSpawn } from 'child_process'; 3 | 4 | const metaCharsRegExp = /([ !"%&()*,;<>?[\]^`|])/g; 5 | 6 | export const spawn = ( 7 | command: string, 8 | arguments_?: readonly string[], 9 | options?: SpawnOptionsWithoutStdio, 10 | ): ChildProcess => { 11 | if (process.platform !== 'win32') { 12 | return cpSpawn(command, arguments_, options); 13 | } 14 | 15 | const shellCommand = [ 16 | command, 17 | ...(arguments_ || []).map((argument) => 18 | `"${argument}"`.replace(metaCharsRegExp, '^$1'), 19 | ), 20 | ].join(' '); 21 | 22 | return cpSpawn( 23 | process.env['comspec'] || 'cmd.exe', 24 | ['/d', '/s', '/c', `"${shellCommand}"`], 25 | { 26 | ...options, 27 | windowsVerbatimArguments: true, 28 | }, 29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /server/utils/utils.ts: -------------------------------------------------------------------------------- 1 | export const ZERO = 0; 2 | export const ONE = 1; 3 | 4 | export const HTTP_STATUS_OK = 200; 5 | export const HTTP_STATUS_BAD_REQUEST = 400; 6 | export const HTTP_STATUS_NOT_FOUND = 404; 7 | 8 | export const notEmpty = ( 9 | value: TValue | null | undefined, 10 | ): value is TValue => { 11 | return value !== null && value !== undefined; 12 | }; 13 | -------------------------------------------------------------------------------- /tests/add-multiple.test.ts: -------------------------------------------------------------------------------- 1 | import { HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_OK } from '../server/utils/utils'; 2 | import type { TestProject } from './tests-utils'; 3 | import { 4 | dependencyTypes, 5 | managers, 6 | prepareTestProject, 7 | TEST, 8 | } from './tests-utils'; 9 | 10 | describe.each(dependencyTypes)( 11 | 'add multiple dependencies as %s', 12 | (dependencyType) => { 13 | describe.each(managers)('%s', (manager) => { 14 | // eslint-disable-next-line @typescript-eslint/init-declarations 15 | let project: TestProject; 16 | 17 | beforeAll(async () => { 18 | project = await prepareTestProject('add-multiple', manager); 19 | }); 20 | 21 | test('invalid name', async () => { 22 | await project.prepareClear({}); 23 | 24 | const response = await project.requestAdd(dependencyType, [ 25 | { name: 'sdmvladbf3', version: 'v1.0.0' }, 26 | { name: 'fasdf2', version: 'v1.0.0' }, 27 | ]); 28 | expect(response.status).toBe(HTTP_STATUS_BAD_REQUEST); 29 | 30 | const fastResponse = await project.requestGetFast(); 31 | const fullResponse = await project.requestGetFull(); 32 | 33 | expect(fastResponse.body).toEqual([]); 34 | expect(fullResponse.body).toEqual([]); 35 | }); 36 | 37 | test('invalid version', async () => { 38 | await project.prepareClear({}); 39 | 40 | const response = await project.requestAdd(dependencyType, [ 41 | { name: 'npm-gui-tests', version: 'v3.0.0' }, 42 | { name: 'npm-gui-tests-2', version: 'v15.0.0' }, 43 | ]); 44 | expect(response.status).toBe(HTTP_STATUS_BAD_REQUEST); 45 | 46 | const fastResponse = await project.requestGetFast(); 47 | const fullResponse = await project.requestGetFull(); 48 | 49 | expect(fastResponse.body).toEqual([]); 50 | expect(fullResponse.body).toEqual([]); 51 | }); 52 | 53 | test('correct dependency, no version', async () => { 54 | await project.prepareClear({}); 55 | 56 | const response = await project.requestAdd(dependencyType, [ 57 | { name: 'npm-gui-tests' }, 58 | { name: 'npm-gui-tests-2' }, 59 | ]); 60 | expect(response.status).toBe(HTTP_STATUS_OK); 61 | 62 | const fastResponse = await project.requestGetFast(); 63 | const fullResponse = await project.requestGetFull(); 64 | 65 | expect(fastResponse.body).toIncludeAllMembers([ 66 | { 67 | ...TEST[manager].PKG_A_UP, 68 | required: '^2.1.1', 69 | type: dependencyType, 70 | }, 71 | { 72 | ...TEST[manager].PKG_B_UP, 73 | required: '^1.0.1', 74 | type: dependencyType, 75 | }, 76 | ]); 77 | 78 | expect(fullResponse.body).toIncludeAllMembers([ 79 | { ...TEST[manager].PKG_A_UP_NEWEST, type: dependencyType }, 80 | { ...TEST[manager].PKG_B_UP_NEWEST, type: dependencyType }, 81 | ]); 82 | }); 83 | 84 | test('correct dependency, with version', async () => { 85 | await project.prepareClear({}); 86 | 87 | const response = await project.requestAdd(dependencyType, [ 88 | { name: 'npm-gui-tests', version: '^1.0.0' }, 89 | { name: 'npm-gui-tests-2', version: '^1.0.0' }, 90 | ]); 91 | expect(response.status).toBe(HTTP_STATUS_OK); 92 | 93 | const fastResponse = await project.requestGetFast(); 94 | const fullResponse = await project.requestGetFull(); 95 | 96 | expect(fastResponse.body).toIncludeAllMembers([ 97 | { ...TEST[manager].PKG_A_UP, type: dependencyType }, 98 | { ...TEST[manager].PKG_B_UP, type: dependencyType }, 99 | ]); 100 | expect(fullResponse.body).toIncludeAllMembers([ 101 | { ...TEST[manager].PKG_A_UP_INSTALLED, type: dependencyType }, 102 | { ...TEST[manager].PKG_B_UP_INSTALLED, type: dependencyType }, 103 | ]); 104 | }); 105 | }); 106 | }, 107 | ); 108 | -------------------------------------------------------------------------------- /tests/add-single.test.ts: -------------------------------------------------------------------------------- 1 | import { HTTP_STATUS_OK } from '../server/utils/utils'; 2 | import type { TestProject } from './tests-utils'; 3 | import { 4 | dependencyTypes, 5 | managers, 6 | prepareTestProject, 7 | TEST, 8 | } from './tests-utils'; 9 | 10 | describe.each(dependencyTypes)('add single %s depednency', (dependencyType) => { 11 | describe.each(managers)('as %s', (manager) => { 12 | // eslint-disable-next-line @typescript-eslint/init-declarations 13 | let project: TestProject; 14 | 15 | beforeAll(async () => { 16 | project = await prepareTestProject('add-single', manager); 17 | }); 18 | 19 | test('invalid name', async () => { 20 | await project.prepareClear({}); 21 | 22 | const response = await project.requestAdd(dependencyType, [ 23 | { name: 'sdmvladbf3', version: 'v1.0.0' }, 24 | ]); 25 | expect(response.status).not.toBe(HTTP_STATUS_OK); 26 | 27 | const fastResponse = await project.requestGetFast(); 28 | const fullResponse = await project.requestGetFull(); 29 | 30 | expect(fastResponse.body).toEqual([]); 31 | expect(fullResponse.body).toEqual([]); 32 | }); 33 | 34 | test('invalid version', async () => { 35 | await project.prepareClear({}); 36 | 37 | const response = await project.requestAdd(dependencyType, [ 38 | { name: 'npm-gui-tests', version: 'v3.0.0' }, 39 | ]); 40 | expect(response.status).not.toBe(HTTP_STATUS_OK); 41 | 42 | const fastResponse = await project.requestGetFast(); 43 | const fullResponse = await project.requestGetFull(); 44 | 45 | expect(fastResponse.body).toEqual([]); 46 | expect(fullResponse.body).toEqual([]); 47 | }); 48 | 49 | test('correct dependency, no version', async () => { 50 | await project.prepareClear({}); 51 | 52 | const response = await project.requestAdd(dependencyType, [ 53 | { name: 'npm-gui-tests' }, 54 | ]); 55 | expect(response.status).toBe(HTTP_STATUS_OK); 56 | 57 | const fastResponse = await project.requestGetFast(); 58 | const fullResponse = await project.requestGetFull(); 59 | 60 | expect(fastResponse.body).toPartiallyContain({ 61 | ...TEST[manager].PKG_A, 62 | required: '^2.1.1', 63 | type: dependencyType, 64 | }); 65 | expect(fullResponse.body).toPartiallyContain({ 66 | ...TEST[manager].PKG_A_UP_NEWEST, 67 | type: dependencyType, 68 | }); 69 | }); 70 | 71 | test('correct dependency, with version', async () => { 72 | await project.prepareClear({}); 73 | 74 | const response = await project.requestAdd(dependencyType, [ 75 | { name: 'npm-gui-tests', version: '^1.0.0' }, 76 | ]); 77 | expect(response.status).toBe(HTTP_STATUS_OK); 78 | 79 | const fastResponse = await project.requestGetFast(); 80 | const fullResponse = await project.requestGetFull(); 81 | 82 | expect(fastResponse.body).toPartiallyContain({ 83 | ...TEST[manager].PKG_A_UP, 84 | type: dependencyType, 85 | }); 86 | expect(fullResponse.body).toPartiallyContain({ 87 | ...TEST[manager].PKG_A_UP_INSTALLED, 88 | type: dependencyType, 89 | }); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /tests/cache.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-statements */ 2 | import type api from 'supertest'; 3 | 4 | import { HTTP_STATUS_OK } from '../server/utils/utils'; 5 | import type { TestProject } from './tests-utils'; 6 | import { managers, prepareTestProject, TEST } from './tests-utils'; 7 | 8 | const withTimeExecution = async ( 9 | callback: () => Promise, 10 | ): Promise<[number, T]> => { 11 | const start = Date.now(); 12 | const returnValue = await callback(); 13 | const executionTime = Date.now() - start; 14 | 15 | return [executionTime, returnValue]; 16 | }; 17 | 18 | describe.each(managers)('%s cache fetching', (manager) => { 19 | // eslint-disable-next-line @typescript-eslint/init-declarations 20 | let project: TestProject; 21 | 22 | beforeAll(async () => { 23 | project = await prepareTestProject('cache', manager); 24 | }); 25 | 26 | test('next full response calls should be faster than first', async () => { 27 | await project.prepareClear({ 28 | dependencies: { 29 | 'npm-gui-tests': '^1.0.0', 30 | 'npm-gui-tests-2': '^1.0.0', 31 | }, 32 | install: true, 33 | }); 34 | 35 | const [executionTime1] = await withTimeExecution( 36 | async () => await project.requestGetFull(), 37 | ); 38 | expect(executionTime1).toBeGreaterThan(100); 39 | 40 | const [executionTime2] = await withTimeExecution( 41 | async () => await project.requestGetFull(), 42 | ); 43 | expect(executionTime2).toBeLessThan(100); 44 | 45 | const [executionTime3] = await withTimeExecution( 46 | async () => await project.requestGetFull(), 47 | ); 48 | expect(executionTime3).toBeLessThan(100); 49 | 50 | expect(executionTime2).toBeLessThan(executionTime1); 51 | expect(executionTime3).toBeLessThan(executionTime1); 52 | }); 53 | 54 | test('cache update on add/delete dependency', async () => { 55 | await project.prepareClear({ 56 | dependencies: { 57 | 'npm-gui-tests': '^1.0.0', 58 | }, 59 | install: true, 60 | }); 61 | 62 | // create cache request 63 | const response0 = await project.requestGetFull(); 64 | expect(response0.status).toBe(HTTP_STATUS_OK); 65 | expect(response0.body).toIncludeAllMembers([TEST[manager].PKG_A_INSTALLED]); 66 | 67 | // update cache request (new dependency) 68 | const response1 = await project.requestAdd('prod', [ 69 | { name: 'npm-gui-tests-2', version: '^1.0.0' }, 70 | ]); 71 | expect(response1.status).toBe(HTTP_STATUS_OK); 72 | 73 | // listing 74 | const [executionTime1, full1] = await withTimeExecution( 75 | async () => await project.requestGetFull(), 76 | ); 77 | expect(executionTime1).toBeLessThan(100); 78 | expect(full1.body).toIncludeAllMembers([ 79 | TEST[manager].PKG_A_INSTALLED, 80 | TEST[manager].PKG_B_UP_INSTALLED, 81 | ]); 82 | 83 | // update cache request (already installed dependency) 84 | const response3 = await project.requestAdd('prod', [ 85 | { name: 'npm-gui-tests', version: '^2.1.1' }, 86 | ]); 87 | expect(response3.status).toBe(HTTP_STATUS_OK); 88 | 89 | // listing 90 | const [executionTime2, full2] = await withTimeExecution( 91 | async () => await project.requestGetFull(), 92 | ); 93 | expect(executionTime2).toBeLessThan(100); 94 | expect(full2.body).toIncludeAllMembers([ 95 | TEST[manager].PKG_A_UP_NEWEST, 96 | TEST[manager].PKG_B_UP_INSTALLED, 97 | ]); 98 | 99 | // update cache request (remove installed dependency) 100 | const response2 = await project.requestDel('prod', [ 101 | { name: 'npm-gui-tests' }, 102 | ]); 103 | expect(response2.status).toBe(HTTP_STATUS_OK); 104 | 105 | // listing 106 | const [executionTime3, full3] = await withTimeExecution( 107 | async () => await project.requestGetFull(), 108 | ); 109 | expect(executionTime3).toBeLessThan(100); 110 | expect(full3.body).toIncludeAllMembers([TEST[manager].PKG_B_UP_INSTALLED]); 111 | expect(full3.body).toHaveLength(1); 112 | 113 | // update cache request (remove unknown dependency) 114 | const response4 = await project.requestDel('prod', [ 115 | { name: 'asdfasdfasdf' }, 116 | ]); 117 | expect(response4.status).toBe(HTTP_STATUS_OK); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /tests/comparators.test.ts: -------------------------------------------------------------------------------- 1 | import type { TestProject } from './tests-utils'; 2 | import { managers, prepareTestProject } from './tests-utils'; 3 | 4 | describe.each(managers)('%s comparators', (manager) => { 5 | describe.each([ 6 | { required: '*', installed: '2.1.1', latest: null, wanted: null }, 7 | { required: '1', installed: '1.1.1', latest: '2.1.1', wanted: null }, 8 | { required: '1.*', installed: '1.1.1', latest: '2.1.1', wanted: null }, 9 | { required: '1.0.0', installed: '1.0.0', latest: '2.1.1', wanted: null }, 10 | { 11 | required: 'v1.0.0', 12 | installed: '1.0.0', 13 | latest: '2.1.1', 14 | wanted: null, 15 | }, 16 | { 17 | required: '=1.0.0', 18 | installed: '1.0.0', 19 | latest: '2.1.1', 20 | wanted: null, 21 | }, 22 | { 23 | required: '^1.0.0', 24 | installed: '1.1.1', 25 | latest: '2.1.1', 26 | wanted: null, 27 | }, 28 | { required: '>=1.0.0', installed: '2.1.1', latest: null, wanted: null }, 29 | { 30 | required: '~1.0.0', 31 | installed: '1.0.1', 32 | latest: '2.1.1', 33 | wanted: null, 34 | }, 35 | { 36 | required: '>=1.0.0 < 2.0.0', 37 | installed: '1.1.1', 38 | latest: '2.1.1', 39 | wanted: null, 40 | }, 41 | { 42 | required: '1.0.0 - 2.0.0', 43 | installed: '2.0.0', 44 | latest: '2.1.1', 45 | wanted: null, 46 | }, 47 | // '1.0.0-beta.1', 48 | ])('%s', ({ required, installed, wanted, latest }) => { 49 | // eslint-disable-next-line @typescript-eslint/init-declarations 50 | let project: TestProject; 51 | 52 | beforeAll(async () => { 53 | project = await prepareTestProject('comparators', manager); 54 | }); 55 | 56 | test('uninstalled', async () => { 57 | await project.prepareClear({ 58 | dependencies: { 'npm-gui-tests': required }, 59 | }); 60 | 61 | const fastResponse = await project.requestGetFast(); 62 | const fullResponse = await project.requestGetFull(); 63 | 64 | expect(fastResponse.body).toIncludeAllMembers([ 65 | { 66 | manager, 67 | name: 'npm-gui-tests', 68 | required, 69 | type: 'prod', 70 | }, 71 | ]); 72 | 73 | expect(fullResponse.body).toIncludeAllMembers([ 74 | { 75 | manager, 76 | name: 'npm-gui-tests', 77 | required, 78 | installed: null, 79 | latest: null, 80 | wanted: null, 81 | type: 'prod', 82 | }, 83 | ]); 84 | }); 85 | 86 | test('installed', async () => { 87 | await project.prepareClear({ 88 | dependencies: { 'npm-gui-tests': required }, 89 | install: true, 90 | }); 91 | 92 | const fastResponse = await project.requestGetFast(); 93 | const fullResponse = await project.requestGetFull(); 94 | 95 | expect(fastResponse.body).toIncludeAllMembers([ 96 | { 97 | manager, 98 | name: 'npm-gui-tests', 99 | required, 100 | type: 'prod', 101 | }, 102 | ]); 103 | 104 | expect(fullResponse.body).toIncludeAllMembers([ 105 | { 106 | manager, 107 | name: 'npm-gui-tests', 108 | required, 109 | installed, 110 | latest, 111 | wanted, 112 | type: 'prod', 113 | }, 114 | ]); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /tests/delete.test.ts: -------------------------------------------------------------------------------- 1 | import { HTTP_STATUS_OK } from '../server/utils/utils'; 2 | import type { TestProject } from './tests-utils'; 3 | import { managers, prepareTestProject, TEST } from './tests-utils'; 4 | 5 | describe.each(managers)('%s install', (manager) => { 6 | // eslint-disable-next-line @typescript-eslint/init-declarations 7 | let project: TestProject; 8 | 9 | beforeAll(async () => { 10 | project = await prepareTestProject('delete', manager); 11 | }); 12 | 13 | test('uninstalled invalid name', async () => { 14 | await project.prepareClear({ 15 | dependencies: { 'npm-gui-tests': '^1.0.0' }, 16 | }); 17 | 18 | const response = await project.requestDel('prod', [{ name: 'sdmvladbf3' }]); 19 | expect(response.status).toBe(HTTP_STATUS_OK); 20 | 21 | const fastResponse = await project.requestGetFast(); 22 | 23 | expect(fastResponse.body).toPartiallyContain(TEST[manager].PKG_A); 24 | }); 25 | 26 | test('uninstalled valid name', async () => { 27 | await project.prepareClear({ 28 | dependencies: { 'npm-gui-tests': '^1.0.0' }, 29 | }); 30 | 31 | const response = await project.requestDel('prod', [ 32 | { name: 'npm-gui-tests' }, 33 | ]); 34 | expect(response.status).toBe(HTTP_STATUS_OK); 35 | 36 | const fastResponse = await project.requestGetFast(); 37 | const fullResponse = await project.requestGetFull(); 38 | 39 | expect(fastResponse.body).toEqual([]); 40 | expect(fullResponse.body).toEqual([]); 41 | }); 42 | 43 | test('installed valid name', async () => { 44 | await project.prepareClear({ 45 | dependencies: { 'npm-gui-tests': '^1.0.0' }, 46 | install: true, 47 | }); 48 | const fastResponseBefore = await project.requestGetFast(); 49 | const fullResponseBefore = await project.requestGetFull(); 50 | 51 | expect(fastResponseBefore.body).toPartiallyContain(TEST[manager].PKG_A); 52 | expect(fullResponseBefore.body).toPartiallyContain( 53 | TEST[manager].PKG_A_INSTALLED, 54 | ); 55 | 56 | const response = await project.requestDel('prod', [ 57 | { name: 'npm-gui-tests' }, 58 | ]); 59 | expect(response.status).toBe(HTTP_STATUS_OK); 60 | 61 | const fastResponse = await project.requestGetFast(); 62 | const fullResponse = await project.requestGetFull(); 63 | 64 | expect(fastResponse.body).toEqual([]); 65 | expect(fullResponse.body).toEqual([]); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /tests/explorer.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import api from 'supertest'; 3 | 4 | import { app } from '../server'; 5 | import { HTTP_STATUS_OK } from '../server/utils/utils'; 6 | import { encodePath, prepareTestProject } from './tests-utils'; 7 | 8 | describe(`Explorer`, () => { 9 | test('should return result of pwd when given path is undefined', async () => { 10 | const project = await prepareTestProject('explorer', 'npm'); 11 | await project.prepareClear({}); 12 | 13 | const response = await api(app.server).get('/api/explorer/'); 14 | expect(response.status).toBe(HTTP_STATUS_OK); 15 | expect(response.body.path).not.toBe(undefined); 16 | 17 | expect(response.body.ls).toPartiallyContain({ 18 | isDirectory: true, 19 | isProject: false, 20 | name: 'tests', 21 | }); 22 | }); 23 | 24 | test('should return result when path is defined', async () => { 25 | const project = await prepareTestProject('explorer', 'yarn'); 26 | await project.prepareClear({}); 27 | 28 | const response = await api(app.server).get( 29 | `/api/explorer/${encodePath( 30 | path.join(__dirname, 'test-project-yarn', 'explorer'), 31 | )}`, 32 | ); 33 | expect(response.status).toBe(HTTP_STATUS_OK); 34 | expect(response.body.path).not.toBe(undefined); 35 | 36 | expect(response.body.ls).toPartiallyContain({ 37 | isDirectory: false, 38 | isProject: false, 39 | name: 'somefile', 40 | }); 41 | 42 | expect(response.body.ls).toPartiallyContain({ 43 | isDirectory: false, 44 | isProject: true, 45 | name: 'yarn.lock', 46 | }); 47 | 48 | expect(response.body.ls).toPartiallyContain({ 49 | isDirectory: false, 50 | isProject: true, 51 | name: 'package.json', 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /tests/extras.test.ts: -------------------------------------------------------------------------------- 1 | import type { TestProject } from './tests-utils'; 2 | import { managers, prepareTestProject } from './tests-utils'; 3 | 4 | describe.each(managers)('%s extra info', (manager) => { 5 | // eslint-disable-next-line @typescript-eslint/init-declarations 6 | let project: TestProject; 7 | 8 | beforeAll(async () => { 9 | project = await prepareTestProject('extras', manager); 10 | }); 11 | 12 | test('get', async () => { 13 | await project.prepareClear({ 14 | dependencies: { 'npm-gui-tests': '^1.0.0' }, 15 | }); 16 | 17 | await project.requestInstall(); 18 | 19 | const extrasResponse = await project.requestGetExtras( 20 | manager, 21 | 'npm-gui-tests@2.0.0', 22 | ); 23 | 24 | expect(extrasResponse.body).toIncludeSameMembers([ 25 | { 26 | created: '2018-11-01T10:54:29.810Z', 27 | homepage: 'https://github.com/q-nick/npm-gui-tests#readme', 28 | name: 'npm-gui-tests', 29 | repository: 'git+https://github.com/q-nick/npm-gui-tests.git', 30 | size: 1606, 31 | time: { 32 | '1.0.0': '2018-11-01T10:54:29.979Z', 33 | '1.0.1': '2018-11-01T10:56:06.435Z', 34 | '1.1.0': '2018-11-01T10:56:22.241Z', 35 | '1.1.1': '2018-11-01T10:56:37.871Z', 36 | '2.0.0': '2018-11-01T10:57:00.397Z', 37 | '2.0.1': '2018-11-01T10:57:11.030Z', 38 | '2.1.0': '2018-11-01T10:58:01.883Z', 39 | '2.1.1': '2018-11-01T11:07:19.996Z', 40 | created: '2018-11-01T10:54:29.810Z', 41 | modified: '2022-05-11T12:35:33.226Z', 42 | }, 43 | updated: '2022-05-11T12:35:33.226Z', 44 | version: '2.0.0', 45 | versions: [ 46 | '1.0.0', 47 | '1.0.1', 48 | '1.1.0', 49 | '1.1.1', 50 | '2.0.0', 51 | '2.0.1', 52 | '2.1.0', 53 | '2.1.1', 54 | ], 55 | }, 56 | ]); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /tests/global.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-unassigned-import 2 | import 'jest-extended'; 3 | -------------------------------------------------------------------------------- /tests/global.test.ts: -------------------------------------------------------------------------------- 1 | import { spawnSync } from 'child_process'; 2 | import { readJSONSync, writeJSONSync } from 'fs-extra'; 3 | import path from 'path'; 4 | import api from 'supertest'; 5 | 6 | import { app } from '../server'; 7 | import type { Basic } from '../server/types/dependency.types'; 8 | import { HTTP_STATUS_OK } from '../server/utils/utils'; 9 | 10 | describe('Global Packages', () => { 11 | test('install', async () => { 12 | const response = await api(app.server) 13 | .post('/api/global/dependencies') 14 | .send([{ name: 'npm-gui-tests', version: '1.0.0' }]); 15 | 16 | expect(response.status).toBe(HTTP_STATUS_OK); 17 | }); 18 | 19 | test('fast listing', async () => { 20 | const response = await api(app.server).get( 21 | '/api/global/dependencies/simple', 22 | ); 23 | 24 | expect(response.status).toBe(HTTP_STATUS_OK); 25 | expect(response.body).toPartiallyContain({ 26 | name: 'npm-gui-tests', 27 | manager: 'npm', 28 | installed: '1.0.0', 29 | }); 30 | }); 31 | 32 | test('full listing', async () => { 33 | const response = await api(app.server).get('/api/global/dependencies/full'); 34 | 35 | expect(response.status).toBe(HTTP_STATUS_OK); 36 | expect(response.body).toPartiallyContain({ 37 | name: 'npm-gui-tests', 38 | manager: 'npm', 39 | installed: '1.0.0', 40 | latest: '2.1.1', 41 | }); 42 | }); 43 | 44 | test('uninstalling', async () => { 45 | const response = await api(app.server).delete( 46 | '/api/global/dependencies/global/npm-gui-tests', 47 | ); 48 | 49 | expect(response.status).toBe(HTTP_STATUS_OK); 50 | 51 | const responseListing = await api(app.server).get( 52 | '/api/global/dependencies/simple', 53 | ); 54 | expect(responseListing.status).toBe(HTTP_STATUS_OK); 55 | expect( 56 | responseListing.body.find((d: Basic) => d.name === 'npm-gui-tests'), 57 | ).toBe(undefined); 58 | }); 59 | 60 | test('weird behavior when global package is missing its version', async () => { 61 | await api(app.server) 62 | .post('/api/global/dependencies') 63 | .send([{ name: 'npm-gui-tests', version: '1.0.0' }]); 64 | 65 | // find package.json in global folder 66 | const packageJSONPath = path.join( 67 | spawnSync('npm', ['root', '-g'], { shell: process.platform === 'win32' }) 68 | .stdout.toString() 69 | .replace(/[\n\r]/gm, ''), 70 | 'npm-gui-tests', 71 | 'package.json', 72 | ); 73 | 74 | // remove version from package.json 75 | const packageJSON = readJSONSync(packageJSONPath); 76 | delete packageJSON.version; 77 | writeJSONSync(packageJSONPath, packageJSON); 78 | 79 | const response = await api(app.server).get( 80 | '/api/global/dependencies/simple', 81 | ); 82 | 83 | expect(response.body).toPartiallyContain({ 84 | installed: null, 85 | manager: 'npm', 86 | name: 'npm-gui-tests', 87 | type: 'global', 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /tests/info.test.ts: -------------------------------------------------------------------------------- 1 | import api from 'supertest'; 2 | 3 | import { app } from '../server'; 4 | import { HTTP_STATUS_OK } from '../server/utils/utils'; 5 | 6 | describe.skip(`Info`, () => { 7 | test('should return 200', async () => { 8 | const response = await api(app.server).get('/api/info/unit-test'); 9 | 10 | expect(response.status).toBe(HTTP_STATUS_OK); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /tests/install-force.test.ts: -------------------------------------------------------------------------------- 1 | import type { TestProject } from './tests-utils'; 2 | import { managers, prepareTestProject, TEST } from './tests-utils'; 3 | 4 | describe.each(managers)('%s install', (manager) => { 5 | // eslint-disable-next-line @typescript-eslint/init-declarations 6 | let project: TestProject; 7 | 8 | beforeAll(async () => { 9 | project = await prepareTestProject('install-force', manager); 10 | }); 11 | 12 | test('nothing', async () => { 13 | await project.prepareClear({}); 14 | 15 | await project.requestInstallForce(manager); 16 | 17 | const fastResponse = await project.requestGetFast(); 18 | const fullResponse = await project.requestGetFull(); 19 | 20 | expect(fastResponse.body).toEqual([]); 21 | expect(fullResponse.body).toEqual([]); 22 | }); 23 | 24 | test('uninstalled', async () => { 25 | await project.prepareClear({ 26 | dependencies: { 'npm-gui-tests': '^1.0.0' }, 27 | }); 28 | 29 | await project.requestInstallForce(manager); 30 | 31 | const fastResponse = await project.requestGetFast(); 32 | const fullResponse = await project.requestGetFull(); 33 | 34 | expect(fastResponse.body).toEqual([TEST[manager].PKG_A]); 35 | expect(fullResponse.body).toEqual([TEST[manager].PKG_A_INSTALLED]); 36 | }); 37 | 38 | test('uninstalled', async () => { 39 | await project.prepareClear({ 40 | dependencies: { 'npm-gui-tests': '^1.0.0' }, 41 | install: true, 42 | }); 43 | 44 | await project.requestInstallForce(manager); 45 | 46 | const fastResponse = await project.requestGetFast(); 47 | const fullResponse = await project.requestGetFull(); 48 | 49 | expect(fastResponse.body).toEqual([TEST[manager].PKG_A]); 50 | expect(fullResponse.body).toEqual([TEST[manager].PKG_A_INSTALLED]); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/install.test.ts: -------------------------------------------------------------------------------- 1 | import type { TestProject } from './tests-utils'; 2 | import { managers, prepareTestProject, TEST } from './tests-utils'; 3 | 4 | describe.each(managers)('%s install', (manager) => { 5 | // eslint-disable-next-line @typescript-eslint/init-declarations 6 | let project: TestProject; 7 | 8 | beforeAll(async () => { 9 | project = await prepareTestProject('install', manager); 10 | }); 11 | 12 | test('nothing', async () => { 13 | await project.prepareClear({}); 14 | 15 | await project.requestInstall(); 16 | 17 | const fastResponse = await project.requestGetFast(); 18 | const fullResponse = await project.requestGetFull(); 19 | 20 | expect(fastResponse.body).toEqual([]); 21 | expect(fullResponse.body).toEqual([]); 22 | }); 23 | 24 | test('uninstalled', async () => { 25 | await project.prepareClear({ 26 | dependencies: { 'npm-gui-tests': '^1.0.0' }, 27 | }); 28 | 29 | await project.requestInstall(); 30 | 31 | const fastResponse = await project.requestGetFast(); 32 | const fullResponse = await project.requestGetFull(); 33 | 34 | expect(fastResponse.body).toEqual([TEST[manager].PKG_A]); 35 | expect(fullResponse.body).toEqual([TEST[manager].PKG_A_INSTALLED]); 36 | }); 37 | 38 | test('uninstalled', async () => { 39 | await project.prepareClear({ 40 | dependencies: { 'npm-gui-tests': '^1.0.0' }, 41 | install: true, 42 | }); 43 | 44 | await project.requestInstall(); 45 | 46 | const fastResponse = await project.requestGetFast(); 47 | const fullResponse = await project.requestGetFull(); 48 | 49 | expect(fastResponse.body).toEqual([TEST[manager].PKG_A]); 50 | expect(fullResponse.body).toEqual([TEST[manager].PKG_A_INSTALLED]); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /tests/invalid-project.test.ts: -------------------------------------------------------------------------------- 1 | import { ensureDir } from 'fs-extra'; 2 | import path from 'path'; 3 | import api from 'supertest'; 4 | 5 | import { app } from '../server'; 6 | import { HTTP_STATUS_BAD_REQUEST } from '../server/utils/utils'; 7 | import { encodePath } from './tests-utils'; 8 | 9 | describe(`Invalid project for npm `, () => { 10 | test('should throw error', async () => { 11 | const testDirectoryPath = path.join(__dirname, 'test-project', 'invalid'); 12 | 13 | await ensureDir(testDirectoryPath); 14 | const encodedTestDirectoryPath = encodePath(testDirectoryPath); 15 | 16 | const response = await api(app.server).get( 17 | `/api/project/${encodedTestDirectoryPath}/dependencies/install`, 18 | ); 19 | 20 | expect(response.status).toBe(HTTP_STATUS_BAD_REQUEST); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tests/managers.test.ts: -------------------------------------------------------------------------------- 1 | import api from 'supertest'; 2 | 3 | import { app } from '../server'; 4 | import { HTTP_STATUS_OK } from '../server/utils/utils'; 5 | 6 | describe(`Package Managers`, () => { 7 | test('should return available package managers', async () => { 8 | const response = await api(app.server).get('/api/available-managers/'); 9 | expect(response.status).toBe(HTTP_STATUS_OK); 10 | 11 | expect(response.body).toEqual({ 12 | npm: true, 13 | pnpm: true, 14 | yarn: true, 15 | }); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /tests/search.test.ts: -------------------------------------------------------------------------------- 1 | import api from 'supertest'; 2 | 3 | import { app } from '../server'; 4 | import { HTTP_STATUS_OK } from '../server/utils/utils'; 5 | 6 | describe(`Package Managers`, () => { 7 | test('should return available package managers', async () => { 8 | const response = await api(app.server) 9 | .post('/api/search/npm') 10 | .send({ query: 'npm-gui-tests' }); 11 | 12 | expect(response.status).toBe(HTTP_STATUS_OK); 13 | 14 | expect(response.body).toPartiallyContain({ 15 | name: 'npm-gui-tests', 16 | version: '2.1.1', 17 | repository: 'https://github.com/q-nick/npm-gui-tests', 18 | }); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /tests/setup-tests.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | jest.setTimeout(60000); 3 | -------------------------------------------------------------------------------- /tests/test-package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "npm-gui-test-project", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "UNLICENSED" 11 | } 12 | -------------------------------------------------------------------------------- /tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-turbocharge/node/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "es2015", 5 | "lib": ["es2015"], 6 | "noEmit": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/units.test.ts: -------------------------------------------------------------------------------- 1 | import { executeCommand } from '../server/actions/execute-command'; 2 | import { 3 | getDependenciesFromPackageJson, 4 | getDevelopmentDependenciesFromPackageJson, 5 | getRequiredFromPackageJson, 6 | getTypeFromPackageJson, 7 | } from '../server/utils/get-project-package-json'; 8 | 9 | describe(`get package.json exceptions`, () => { 10 | test('getDependenciesFromPackageJson', () => { 11 | expect(getDependenciesFromPackageJson('anything')).toEqual({}); 12 | }); 13 | 14 | test('getDevDependenciesFromPackageJson', () => { 15 | expect(getDevelopmentDependenciesFromPackageJson('anything')).toEqual({}); 16 | }); 17 | 18 | test('getTypeFromPackageJson', () => { 19 | expect(getTypeFromPackageJson('anything', 'anything')).toBe('extraneous'); 20 | 21 | expect(getTypeFromPackageJson('./', 'anything')).toBe('extraneous'); 22 | }); 23 | 24 | test('getRequiredFromPackageJson', () => { 25 | expect(getRequiredFromPackageJson('anything', 'anything')).toBe(undefined); 26 | 27 | expect(getRequiredFromPackageJson('./', 'anything')).toBe(undefined); 28 | }); 29 | 30 | describe(`execute command exceptions`, () => { 31 | test('empty string', async () => { 32 | await expect(executeCommand('', '')).rejects.toThrowError(); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-turbocharge/node/tsconfig.json", 3 | "references": [ 4 | { "path": "./server" }, 5 | { "path": "./client" }, 6 | { "path": "./tests" } 7 | ], 8 | "compilerOptions": { 9 | "outDir": "./dist" 10 | }, 11 | "files": [] 12 | } 13 | --------------------------------------------------------------------------------