├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .mocharc.jsonc ├── .npmignore ├── .prettierrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── README.md ├── bin └── install-local ├── package-lock.json ├── package.json ├── src ├── LocalInstaller.ts ├── Options.ts ├── cli.ts ├── currentDirectoryInstall.ts ├── executor.ts ├── helpers.ts ├── index.ts ├── progress.ts ├── save.ts ├── siblingInstall.ts ├── tsconfig.json └── utils.ts ├── stryker.conf.json ├── test ├── helpers │ └── producers.ts ├── integration │ ├── cli.it.ts │ └── utils.it.ts ├── setup.ts ├── tsconfig.json └── unit │ ├── LocalInstallerSpec.ts │ ├── OptionsSpec.ts │ ├── cliSpec.ts │ ├── currentDirectoryInstallSpec.ts │ ├── helpersSpec.ts │ ├── progressSpec.ts │ ├── saveSpec.ts │ ├── siblingInstallSpec.ts │ └── utilSpec.ts ├── tsconfig.json └── tsconfig.settings.json /.editorconfig: -------------------------------------------------------------------------------- 1 | [{*.ts,*.js,*jsx,*tsx,*.json,,*.jsonc,*.code-workspace}] 2 | insert_final_newline = true 3 | indent_style = space 4 | indent_size = 2 5 | end_of_line = lf -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: "module" // Allows for the use of imports 6 | }, 7 | extends: [ 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier/@typescript-eslint", 10 | "plugin:prettier/recommended", 11 | ], 12 | rules: { 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | *.png binary -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: ~ 5 | pull_request: ~ 6 | schedule: 7 | - cron: '0 12 * * *' 8 | 9 | jobs: 10 | build_and_test: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node-version: [12.x, 14.x] 15 | os: ['ubuntu-latest', 'windows-latest'] 16 | 17 | runs-on: ${{ matrix.os }} 18 | 19 | steps: 20 | - uses: actions/checkout@v1 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - name: Install dependencies 26 | run: npm ci 27 | - name: Build & lint & test 28 | run: npm run all 29 | 30 | mutation_test: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v1 34 | - name: Install dependencies 35 | run: npm ci 36 | - name: test-mutation 37 | run: npm run test:mutation 38 | env: 39 | STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .stryker-tmp 2 | reports 3 | node_modules 4 | *.map 5 | *.d.ts 6 | /dist -------------------------------------------------------------------------------- /.mocharc.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "require": ["source-map-support/register", "dist/test/setup.js"] 3 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/* 2 | !bin/** 3 | !src/** 4 | !dist/src/** 5 | !README.md 6 | !CHANGELOG.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Attach", 9 | "port": 9229, 10 | "request": "attach", 11 | "skipFiles": [ 12 | "/**" 13 | ], 14 | "type": "pwa-node" 15 | }, 16 | { 17 | "type": "node", 18 | "request": "launch", 19 | "name": "All Tests", 20 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 21 | "args": [ 22 | "--no-timeout", 23 | "--colors", 24 | "${workspaceRoot}/dist/test/unit/*.js", 25 | "${workspaceRoot}/dist/test/integration/*.js" 26 | ], 27 | "internalConsoleOptions": "openOnSessionStart", 28 | "outFiles": [ 29 | "${workspaceRoot}/dist/**/*.js" 30 | ], 31 | "skipFiles": [ 32 | "/**" 33 | ] 34 | }, 35 | { 36 | "type": "node", 37 | "request": "launch", 38 | "name": "Unit Tests", 39 | "program": "${workspaceRoot}/node_modules/mocha/bin/_mocha", 40 | "args": [ 41 | "--no-timeout", 42 | "--colors", 43 | "${workspaceRoot}/dist/test/unit/**/*.js" 44 | ], 45 | "internalConsoleOptions": "openOnSessionStart", 46 | "outFiles": [ 47 | "${workspaceRoot}/dist/**/*.js" 48 | ], 49 | "skipFiles": [ 50 | "/**" 51 | ] 52 | } 53 | ] 54 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.eol": "\n", 3 | "cSpell.words": [ 4 | "execa" 5 | ] 6 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "tsc -w", 8 | "type": "shell", 9 | "command": "npm", 10 | "args": ["start"], 11 | "problemMatcher": "$tsc-watch", 12 | "isBackground": true, 13 | "group": { 14 | "isDefault": true, 15 | "kind": "build" 16 | } 17 | } 18 | ] 19 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [3.0.1](https://github.com/nicojs/node-install-local/compare/v3.0.0...v3.0.1) (2020-10-16) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * **lodash:** remove missing dependency lodash ([b960029](https://github.com/nicojs/node-install-local/commit/b9600290c8b396d48b66a1feb5bc6ea397fffbbb)) 7 | 8 | 9 | 10 | # [3.0.0](https://github.com/nicojs/node-install-local/compare/v1.0.0...v3.0.0) (2020-10-16) 11 | 12 | 13 | ### Features 14 | 15 | * **deps:** dependency update ([#25](https://github.com/nicojs/node-install-local/issues/25)) ([8e46851](https://github.com/nicojs/node-install-local/commit/8e46851a34be1c3654a40624f06444c9d542f871)) 16 | * **install:** ignore other dependencies ([#30](https://github.com/nicojs/node-install-local/issues/30)) ([b9faaae](https://github.com/nicojs/node-install-local/commit/b9faaae3cce413aea350bb383784e10e52afd761)) 17 | * **node:** drop support for node 8 ([07fa721](https://github.com/nicojs/node-install-local/commit/07fa72184fc3780263950997bcfa9631e48c0a6f)) 18 | * **package:** package source files for debugging ([302f703](https://github.com/nicojs/node-install-local/commit/302f7031177191249e3fb737325989254bee1ac2)) 19 | 20 | 21 | ### BREAKING CHANGES 22 | 23 | * **install:** `dependencies` and `devDependencies` will **no longer be installed**. If you want the old behavior, be sure to run `npm install` before you run `install-local`. 24 | * **node:** Node 8 is no longer actively supported 25 | * **package:** Files are now published under `dist` directory. Deep imports (which is a bad practice at best) should be updated accordingly. 26 | * **deps:** Output is now es2017. Drop support for node < 8. 27 | 28 | 29 | # [1.0.0](https://github.com/nicojs/node-install-local/compare/v0.6.2...v1.0.0) (2019-02-12) 30 | 31 | 32 | 33 | ## [0.6.2](https://github.com/nicojs/node-install-local/compare/v0.6.0...v0.6.2) (2018-11-21) 34 | 35 | 36 | ### Bug Fixes 37 | 38 | * **Space in file name:** Support dirs with a space in the name ([2ea3786](https://github.com/nicojs/node-install-local/commit/2ea3786)) 39 | 40 | 41 | 42 | 43 | # [0.6.0](https://github.com/nicojs/node-install-local/compare/v0.5.0...v0.6.0) (2018-07-12) 44 | 45 | 46 | ### Features 47 | 48 | * **install-local:** Support parallel install ([#11](https://github.com/nicojs/node-install-local/issues/11)) ([a2f9524](https://github.com/nicojs/node-install-local/commit/a2f9524)) 49 | 50 | ### Bug fixes 51 | 52 | * **Child process:** increase `maxBuffer` to 10MB 53 | 54 | 55 | 56 | # [0.5.0](https://github.com/nicojs/node-install-local/compare/v0.4.1...v0.5.0) (2018-03-21) 57 | 58 | 59 | ### Features 60 | 61 | * **npmEnv:** add `npmEnv` option to programmatic API ([#7](https://github.com/nicojs/node-install-local/issues/7)) ([c32776a](https://github.com/nicojs/node-install-local/commit/c32776a)) 62 | 63 | 64 | 65 | 66 | ## [0.4.1](https://github.com/nicojs/node-install-local/compare/v0.4.0...v0.4.1) (2017-12-21) 67 | 68 | 69 | 70 | 71 | # [0.4.0](https://github.com/nicojs/node-install-local/compare/v0.3.1...v0.4.0) (2017-06-22) 72 | 73 | 74 | ### Bug Fixes 75 | 76 | * **scoped-packages:** Add support for scoped packages ([7c132d0](https://github.com/nicojs/node-install-local/commit/7c132d0)), closes [#1](https://github.com/nicojs/node-install-local/issues/1) 77 | 78 | 79 | ### Features 80 | 81 | * **reporting:** Report stdout of install ([55bcc9e](https://github.com/nicojs/node-install-local/commit/55bcc9e)) 82 | * **target-siblings:** Target sibling packages ([fc31a7c](https://github.com/nicojs/node-install-local/commit/fc31a7c)) 83 | 84 | 85 | ### BREAKING CHANGES 86 | 87 | * **target-siblings:** The programmatic interface has changed slightly 88 | 89 | 90 | 91 | 92 | ## [0.3.1](https://github.com/nicojs/node-install-local/compare/v0.3.0...v0.3.1) (2017-06-13) 93 | 94 | 95 | ### Bug Fixes 96 | 97 | * **typings:** Add "typings" to package.json to enable typescript support ([ac11871](https://github.com/nicojs/node-install-local/commit/ac11871)) 98 | 99 | 100 | 101 | 102 | # [0.3.0](https://github.com/nicojs/node-install-local/compare/v0.2.0...v0.3.0) (2017-06-13) 103 | 104 | 105 | ### Features 106 | 107 | * **local-install:** Make use of "localDependencies" section in package.json ([86563f8](https://github.com/nicojs/node-install-local/commit/86563f8)) 108 | 109 | 110 | 111 | 112 | # [0.2.0](https://github.com/nicojs/node-install-local/compare/v0.1.0...v0.2.0) (2017-06-07) 113 | 114 | 115 | ### Features 116 | 117 | * **allow-multiple-targets:** Allow multiple target packages ([a5b2098](https://github.com/nicojs/node-install-local/commit/a5b2098)) 118 | * **console:** Add console output when using the cli ([6231b67](https://github.com/nicojs/node-install-local/commit/6231b67)) 119 | 120 | 121 | 122 | 123 | # [0.1.0](https://github.com/nicojs/node-install-local/compare/f4ab2a0...v0.1.0) (2017-06-02) 124 | 125 | 126 | ### Bug Fixes 127 | 128 | * Add rimraf as dev dependency ([f4ab2a0](https://github.com/nicojs/node-install-local/commit/f4ab2a0)) 129 | 130 | 131 | 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fnicojs%2Fnode-install-local%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/nicojs/node-install-local/master) 2 | ![CI](https://github.com/nicojs/node-install-local/workflows/CI/badge.svg) 3 | 4 | # Install local 5 | 6 | Installs npm/yarn packages locally without symlink, also in npm 5. Exactly the same as your production installation, no compromises. 7 | 8 | ## Getting started 9 | 10 | Install with 11 | 12 | ```bash 13 | npm install -g install-local 14 | ``` 15 | 16 | **or** for occasional use, without installation 17 | 18 | ```bash 19 | $ npx install-local 20 | ``` 21 | 22 | You can use install-local from command line or programmatically. 23 | 24 | ## Command line: 25 | 26 | ```bash 27 | Usage: 28 | $ install-local # 1 29 | $ install-local [options] [ ] # 2 30 | $ install-local --target-siblings # 3 31 | ``` 32 | 33 | Installs a package from the filesystem into the current directory. 34 | 35 | Options: 36 | 37 | 38 | * `-h, --help`: Output this help 39 | * `-S, --save`: Saved packages will appear in your package.json under "localDependencies" 40 | * `-T, --target-siblings`: Instead of installing into this package, this package gets installed into sibling packages 41 | which depend on this package by putting it in the "localDependencies". 42 | Useful in a [lerna](https://github.com/lerna/lerna) style monorepo. 43 | 44 | Examples: 45 | * `install-local` 46 | Install the "localDependencies" of your current package 47 | * `install-local ..` 48 | Install the package located in the parent folder into the current directory. 49 | * `install-local --save ../sibling ../sibling2` 50 | Install the packages in 2 sibling directories into the current directory. 51 | * `install-local --help` 52 | Print this help 53 | 54 | See [Programmatically](#programmatically) to see how use `install-local` from node. 55 | 56 | ## Why? 57 | 58 | Why installing packages locally? There are a number of use cases. 59 | 60 | 1. You want to test if the installation of your package results in expected behavior (test your .npmignore file, etc) 61 | 1. You want to install a package locally in a [lernajs-style](http://lernajs.io/) [monorepo](https://github.com/babel/babel/blob/master/doc/design/monorepo.md) 62 | 1. You just want to test a fork of a dependency, after building it locally. 63 | 64 | ## What's wrong with [npm-link](https://docs.npmjs.com/cli/link)? 65 | 66 | Well... nothing is _wrong_ with npm link. It's just not covering all use cases. 67 | 68 | For example, if your using typescript and you `npm link` a dependency from a _parent_ directory, you might end up with infinite ts source files, resulting in an out-of-memory error: 69 | 70 | ``` 71 | FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory 72 | ``` 73 | 74 | An other reason is with `npm link` your **not** testing if your package actually installs correctly. You might have files in there that will not be there after installation. 75 | 76 | ## Can't i use `npm i file:`? 77 | 78 | You could use `npm install file:..` versions of npm prior to version 5. It installed the package locally. Since version 5, the functionality changed to `npm link` instead. More info here: https://github.com/npm/npm/pull/15900 79 | 80 | ## How to guarantee a production-like install 81 | 82 | To guarantee the production-like installation of your dependency, `install-local` uses [`npm pack`](https://docs.npmjs.com/cli/pack) and [`npm install `](https://docs.npmjs.com/cli/install) under the hood. This is as close as production-like as it gets. 83 | 84 | ## Programmatically 85 | 86 | _Typings are included for all your TypeScript programmers out there_ 87 | 88 | ```javascript 89 | const { cli, execute, Options, progress, LocalInstaller} = require('install-local'); 90 | ``` 91 | 92 | ### Use the CLI programmatically 93 | 94 | Execute the cli functions with the `cli` function. It returns a promise: 95 | 96 | ```javascript 97 | cli(['node', 'install-local', '--save', '../sibling-dependency', '../sibling-dependency2']) 98 | .then(() => console.log('done')) 99 | .catch(err => console.error('err')); 100 | ``` 101 | 102 | Or a slightly cleaner api: 103 | 104 | ```javascript 105 | execute({ 106 | validate: () => true, 107 | dependencies: ['../sibling-dependency', '../sibling-dependency2'], 108 | save: true, 109 | targetSiblings: false 110 | }) 111 | ``` 112 | 113 | ### Install dependencies locally 114 | 115 | Use the `LocalInstaller` to install local dependencies into multiple directories. 116 | 117 | For example: 118 | 119 | ```javascript 120 | const localInstaller = new LocalInstaller({ 121 | /*1*/ '.': ['../sibling1', '../sibling2'], 122 | /*2*/ '../dependant': ['.'] 123 | }); 124 | progress(localInstaller); 125 | localInstaller.install() 126 | .then(() => console.log('done')) 127 | .catch(err => console.error(err)); 128 | ``` 129 | 130 | 1. This will install packages located in the directories "sibling1" and "sibling2" next to the current working directory into the package located in the current working directory (`'.'`) 131 | 2. This will install the package located in the current working directory (`'.'`) into the package located in 132 | the "dependant" directory located next to the current working directory. 133 | 134 | Construct the `LocalInstall` by using an object. The properties of this object are the relative package locations to install into. The array values are the packages to be installed. Use the `install()` method to install, returns a promise. 135 | 136 | If you want the progress reporting like the CLI has: use `progress(localInstaller)`; 137 | 138 | ##### Passing npm env variables 139 | 140 | In some cases it might be useful to control the env variables for npm. For example when you want npm to rebuild native node modules against Electron headers. You can do it by passing `options` to `LocalInstaller`'s constructor. 141 | 142 | ```javascript 143 | const localInstaller = new LocalInstaller( 144 | { '.': ['../sibling'] }, 145 | { npmEnv: { envVar: 'envValue' } } 146 | ); 147 | ``` 148 | 149 | Because the value provided for `npmEnv` will override the environment of the npm execution, you may want to extend the existing environment so that required values such as `PATH` are preserved: 150 | 151 | ```javascript 152 | const localInstaller = new LocalInstaller( 153 | { '.': ['../sibling'] }, 154 | { npmEnv: Object.assign({}, process.env, { envVar: 'envValue' }) } 155 | ); 156 | ``` 157 | -------------------------------------------------------------------------------- /bin/install-local: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | process.title = 'install-local'; 3 | require('../dist/src/index').cli(process.argv).catch(err => { 4 | console.error(err); 5 | process.exit(1); 6 | }); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "install-local", 3 | "version": "3.0.1", 4 | "description": "A small module for installing local packages. Works for both npm >= 5 and older versions.", 5 | "main": "dist/src/index.js", 6 | "scripts": { 7 | "all": "npm run clean && npm run build && npm run lint && npm run test", 8 | "clean": "rimraf dist reports .stryker-tmp", 9 | "build": "tsc -b", 10 | "lint": "eslint --ignore-path .gitignore --ext .ts . && prettier --check .github/**/*.yml", 11 | "test": "npm run test:unit && npm run test:integration", 12 | "test:unit": "mocha dist/test/unit/**/*.js", 13 | "test:integration": "mocha --timeout 30000 dist/test/integration/**/*.js", 14 | "test:mutation": "stryker run", 15 | "start": "tsc -b -w", 16 | "preversion": "npm run all", 17 | "version": "conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md", 18 | "postversion": "npm publish && git push && git push --tags", 19 | "release:patch": "npm version patch -m \"chore(release): %s\"", 20 | "release:minor": "npm version minor -m \"chore(release): %s\"", 21 | "release:major": "npm version major -m \"chore(release): %s\"" 22 | }, 23 | "bin": { 24 | "install-local": "bin/install-local" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/nicojs/node-install-local.git" 29 | }, 30 | "keywords": [ 31 | "npm", 32 | "install", 33 | "local", 34 | "yarn" 35 | ], 36 | "author": "Nico Jansen ", 37 | "license": "Apache-2.0", 38 | "bugs": { 39 | "url": "https://github.com/nicojs/node-install-local/issues" 40 | }, 41 | "engines": { 42 | "node": ">=10" 43 | }, 44 | "homepage": "https://github.com/nicojs/node-install-local#readme", 45 | "devDependencies": { 46 | "@stryker-mutator/core": "^4.0.0", 47 | "@stryker-mutator/mocha-runner": "^4.0.0", 48 | "@stryker-mutator/typescript-checker": "^4.0.0", 49 | "@types/chai": "^4.2.13", 50 | "@types/chai-as-promised": "7.1.3", 51 | "@types/lodash.flatmap": "^4.5.6", 52 | "@types/mocha": "^8.0.3", 53 | "@types/node": "^14.11.8", 54 | "@types/rimraf": "^3.0.0", 55 | "@types/semver": "^7.3.4", 56 | "@types/sinon": "^9.0.8", 57 | "@types/sinon-chai": "^3.2.5", 58 | "@types/uniqid": "^5.2.0", 59 | "@typescript-eslint/eslint-plugin": "^4.4.1", 60 | "@typescript-eslint/parser": "^4.4.1", 61 | "chai": "^4.2.0", 62 | "chai-as-promised": "^7.1.1", 63 | "conventional-changelog-cli": "^2.1.0", 64 | "eslint": "^7.11.0", 65 | "eslint-config-prettier": "^6.12.0", 66 | "eslint-plugin-prettier": "^3.1.4", 67 | "mocha": "^8.1.3", 68 | "prettier": "^2.1.2", 69 | "semver": "^7.3.2", 70 | "sinon": "^9.2.0", 71 | "sinon-chai": "^3.5.0", 72 | "source-map-support": "^0.5.19", 73 | "typescript": "^4.0.3" 74 | }, 75 | "dependencies": { 76 | "execa": "^4.0.3", 77 | "lodash.flatmap": "^4.5.0", 78 | "rimraf": "^3.0.2", 79 | "uniqid": "^5.2.0" 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/LocalInstaller.ts: -------------------------------------------------------------------------------- 1 | import flatMap from 'lodash.flatmap'; 2 | import { EventEmitter } from 'events'; 3 | import { promises as fs } from 'fs'; 4 | import path from 'path'; 5 | import { readPackageJson } from './helpers'; 6 | import { InstallTarget, PackageJson } from './index'; 7 | import { exec, del, getRandomTmpDir } from './utils'; 8 | import type { Options as ExecaOptions } from 'execa'; 9 | 10 | interface PackageByDirectory { 11 | [directory: string]: PackageJson; 12 | } 13 | 14 | export interface Env { 15 | [name: string]: string; 16 | } 17 | 18 | export interface Options { 19 | npmEnv?: Env; 20 | } 21 | 22 | export interface ListByPackage { 23 | [key: string]: string[]; 24 | } 25 | 26 | const TEN_MEGA_BYTE = 1024 * 1024 * 10; 27 | 28 | interface EventMap { 29 | install_targets_identified: [InstallTarget[]]; 30 | install_start: [ListByPackage]; 31 | installed: [pkg: string, stdout: string, stderr: string]; 32 | packing_start: [allSources: string[]]; 33 | packed: [location: string]; 34 | packing_end: []; 35 | install_end: []; 36 | } 37 | 38 | export class LocalInstaller extends EventEmitter { 39 | private sourcesByTarget: ListByPackage; 40 | private options: Options; 41 | private uniqueDir: string; 42 | 43 | constructor(sourcesByTarget: ListByPackage, options?: Options) { 44 | super(); 45 | 46 | this.sourcesByTarget = resolve(sourcesByTarget); 47 | this.options = Object.assign({}, options); 48 | this.uniqueDir = getRandomTmpDir('node-local-install-'); 49 | } 50 | 51 | public on( 52 | event: TEventName, 53 | listener: (...args: EventMap[TEventName]) => void, 54 | ): this { 55 | // @ts-expect-error the 'listener' here is reduced to `never` 56 | return super.on(event, listener); 57 | } 58 | 59 | public emit( 60 | event: TEventName, 61 | ...args: EventMap[TEventName] 62 | ): boolean { 63 | return super.emit(event, ...args); 64 | } 65 | 66 | public async install(): Promise { 67 | await this.createTmpDirectory(this.uniqueDir); 68 | 69 | const packages = await this.resolvePackages(); 70 | const installTargets = this.identifyInstallTargets(packages); 71 | 72 | await this.packAll(); 73 | await this.installAll(installTargets); 74 | await this.removeTmpDirectory(); 75 | 76 | return installTargets; 77 | } 78 | 79 | public async createTmpDirectory(tmpDir: string): Promise { 80 | return fs.mkdir(tmpDir); 81 | } 82 | 83 | private async installAll(installTargets: InstallTarget[]): Promise { 84 | this.emit('install_start', this.sourcesByTarget); 85 | await Promise.all(installTargets.map((target) => this.installOne(target))); 86 | this.emit('install_end'); 87 | } 88 | 89 | private async installOne(target: InstallTarget): Promise { 90 | const toInstall = target.sources.map((source) => 91 | resolvePackFile(this.uniqueDir, source.packageJson), 92 | ); 93 | const options: ExecaOptions = { 94 | cwd: target.directory, 95 | maxBuffer: TEN_MEGA_BYTE, 96 | env: this.options.npmEnv, 97 | }; 98 | const { stdout, stderr } = await exec( 99 | 'npm', 100 | ['i', '--no-save', '--no-package-lock', ...toInstall], 101 | options, 102 | ); 103 | this.emit( 104 | 'installed', 105 | target.packageJson.name, 106 | stdout.toString(), 107 | stderr.toString(), 108 | ); 109 | } 110 | 111 | private async resolvePackages(): Promise { 112 | const uniqueDirectories = new Set( 113 | Object.keys(this.sourcesByTarget).concat( 114 | flatMap( 115 | Object.keys(this.sourcesByTarget), 116 | (target) => this.sourcesByTarget[target], 117 | ), 118 | ), 119 | ); 120 | const allPackages = Promise.all( 121 | Array.from(uniqueDirectories).map((directory) => 122 | readPackageJson(directory).then((packageJson) => ({ 123 | directory, 124 | packageJson, 125 | })), 126 | ), 127 | ); 128 | const packages = await allPackages; 129 | const packageByDirectory: PackageByDirectory = {}; 130 | packages.forEach( 131 | (pkg) => (packageByDirectory[pkg.directory] = pkg.packageJson), 132 | ); 133 | return packageByDirectory; 134 | } 135 | 136 | private identifyInstallTargets( 137 | packages: PackageByDirectory, 138 | ): InstallTarget[] { 139 | const installTargets = Object.keys(this.sourcesByTarget).map((target) => ({ 140 | directory: target, 141 | packageJson: packages[target], 142 | sources: this.sourcesByTarget[target].map((source) => ({ 143 | directory: source, 144 | packageJson: packages[source], 145 | })), 146 | })); 147 | this.emit('install_targets_identified', installTargets); 148 | return installTargets; 149 | } 150 | 151 | private async packAll(): Promise { 152 | const allSources = Array.from( 153 | new Set( 154 | flatMap( 155 | Object.keys(this.sourcesByTarget), 156 | (target) => this.sourcesByTarget[target], 157 | ), 158 | ), 159 | ); 160 | this.emit('packing_start', allSources); 161 | await Promise.all(allSources.map((source) => this.packOne(source))); 162 | this.emit('packing_end'); 163 | } 164 | 165 | private async packOne(directory: string): Promise { 166 | await exec('npm', ['pack', directory], { 167 | cwd: this.uniqueDir, 168 | maxBuffer: TEN_MEGA_BYTE, 169 | }); 170 | this.emit('packed', directory); 171 | } 172 | 173 | private removeTmpDirectory(): Promise { 174 | return del(this.uniqueDir); 175 | } 176 | } 177 | 178 | function resolvePackFile(dir: string, pkg: PackageJson) { 179 | // Don't forget about scoped packages 180 | const scopeIndex = pkg.name.indexOf('@'); 181 | const slashIndex = pkg.name.indexOf('/'); 182 | if (scopeIndex === 0 && slashIndex > 0) { 183 | // @s/b -> s-b-x.x.x.tgz 184 | return path.resolve( 185 | dir, 186 | `${pkg.name.substr(1, slashIndex - 1)}-${pkg.name.substr( 187 | slashIndex + 1, 188 | )}-${pkg.version}.tgz`, 189 | ); 190 | } else { 191 | // b -> b-x.x.x.tgz 192 | return path.resolve(dir, `${pkg.name}-${pkg.version}.tgz`); 193 | } 194 | } 195 | 196 | export function resolve(packagesByTarget: ListByPackage): ListByPackage { 197 | const resolvedPackages: ListByPackage = {}; 198 | Object.keys(packagesByTarget).forEach((localTarget) => { 199 | resolvedPackages[path.resolve(localTarget)] = Array.from( 200 | new Set(packagesByTarget[localTarget].map((pkg) => path.resolve(pkg))), 201 | ); 202 | }); 203 | return resolvedPackages; 204 | } 205 | -------------------------------------------------------------------------------- /src/Options.ts: -------------------------------------------------------------------------------- 1 | export class Options { 2 | public readonly dependencies: string[]; 3 | public readonly options: string[]; 4 | 5 | constructor(argv: string[]) { 6 | const args = argv // strip the "node install-local" part. 7 | .filter((_, i) => i > 1); 8 | this.dependencies = args.filter((arg) => arg.substr(0, 1) !== '-'); 9 | this.options = args.filter((arg) => arg.substr(0, 1) === '-'); 10 | } 11 | 12 | public validate(): Promise { 13 | if (this.dependencies.length > 0 && this.targetSiblings) { 14 | return Promise.reject( 15 | `Invalid use of option --target-siblings. Cannot be used together with a dependency list`, 16 | ); 17 | } else if (this.targetSiblings && this.save) { 18 | return Promise.reject( 19 | `Invalid use of option --target-siblings. Cannot be used together with --save`, 20 | ); 21 | } else { 22 | return Promise.resolve(); 23 | } 24 | } 25 | 26 | public get help(): boolean { 27 | return this.flag('-h', '--help'); 28 | } 29 | 30 | public get targetSiblings(): boolean { 31 | return this.flag('-T', '--target-siblings'); 32 | } 33 | 34 | public get save(): boolean { 35 | return this.flag('-S', '--save'); 36 | } 37 | 38 | private flag(...options: string[]) { 39 | return options.some((_) => this.options.indexOf(_) >= 0); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { execute, Options } from './index'; 2 | 3 | export async function cli(argv: string[]): Promise { 4 | const l = console.log; 5 | const options = new Options(argv); 6 | if (options.help) { 7 | l(); 8 | l('Install packages locally.'); 9 | l(); 10 | l('Usage:'); 11 | l(' install-local'); 12 | l(' install-local [options] [, , ...]'); 13 | l(' install-local --target-siblings'); 14 | l(); 15 | l('Installs packages from the filesystem into the current directory.'); 16 | l( 17 | 'Or installs the package located in the current directory to sibling packages that depend on this package with --target-siblings.', 18 | ); 19 | l(); 20 | l('Options: '); 21 | l(); 22 | l(' -h, --help Output this help'); 23 | l( 24 | ' -S, --save Saved packages will appear in your package.json under "localDependencies"', 25 | ); 26 | l( 27 | ' -T, --target-siblings Instead of installing into this package, this package gets installed into sibling packages', 28 | ); 29 | l( 30 | ' which depend on this package by putting it in the "localDependencies".', 31 | ); 32 | l( 33 | ' Useful in a [lerna](https://github.com/lerna/lerna) style monorepo.', 34 | ); 35 | l(); 36 | l('Examples: '); 37 | l(' install-local'); 38 | l(' install the "localDependencies" of your current package'); 39 | l(' install-local ..'); 40 | l( 41 | ' install the package located in the parent folder into the current directory.', 42 | ); 43 | l(' install-local --save ../sibling ../sibling2'); 44 | l( 45 | ' install the packages of 2 sibling directories into the current directory and save them to "localDependencies" in your package.json file.', 46 | ); 47 | } else { 48 | await options.validate(); 49 | await execute(options); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/currentDirectoryInstall.ts: -------------------------------------------------------------------------------- 1 | import { readPackageJson } from './helpers'; 2 | import { LocalInstaller, progress, saveIfNeeded } from './index'; 3 | import { Options } from './Options'; 4 | 5 | export async function currentDirectoryInstall(options: Options): Promise { 6 | const localDependencies = await readLocalDependencies(options.dependencies); 7 | const installer = new LocalInstaller({ '.': localDependencies }); 8 | progress(installer); 9 | const targets = await installer.install(); 10 | await saveIfNeeded(targets, options); 11 | } 12 | 13 | async function readLocalDependencies( 14 | dependenciesFromArguments: string[], 15 | ): Promise { 16 | if (dependenciesFromArguments.length) { 17 | return dependenciesFromArguments; 18 | } else { 19 | const pkg = await readPackageJson('.'); 20 | if (pkg.localDependencies) { 21 | return Object.values(pkg.localDependencies); 22 | } else { 23 | return []; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/executor.ts: -------------------------------------------------------------------------------- 1 | import { currentDirectoryInstall, Options, siblingInstall } from './index'; 2 | 3 | export function execute(options: Options): Promise { 4 | if (options.targetSiblings) { 5 | return siblingInstall(); 6 | } else { 7 | return currentDirectoryInstall(options); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import path from 'path'; 3 | import { PackageJson } from './index'; 4 | 5 | export async function readPackageJson(from: string): Promise { 6 | const content = await fs.readFile(path.join(from, 'package.json'), 'utf8'); 7 | return JSON.parse(content) as PackageJson; 8 | } 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { ListByPackage, LocalInstaller } from './LocalInstaller'; 2 | export { progress } from './progress'; 3 | export { saveIfNeeded } from './save'; 4 | export { currentDirectoryInstall } from './currentDirectoryInstall'; 5 | export { siblingInstall } from './siblingInstall'; 6 | export { execute } from './executor'; 7 | export { cli } from './cli'; 8 | export { Options } from './Options'; 9 | 10 | export interface Package { 11 | directory: string; 12 | packageJson: PackageJson; 13 | } 14 | 15 | export interface InstallTarget extends Package { 16 | sources: Package[]; 17 | } 18 | 19 | export interface PackageJson { 20 | name: string; 21 | version: string; 22 | localDependencies?: Dependencies; 23 | devDependencies?: Dependencies; 24 | dependencies?: Dependencies; 25 | } 26 | 27 | export interface Dependencies { 28 | [name: string]: string; 29 | } 30 | -------------------------------------------------------------------------------- /src/progress.ts: -------------------------------------------------------------------------------- 1 | import type { WriteStream } from 'tty'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | import { LocalInstaller } from './LocalInstaller'; 5 | 6 | class ProgressKeeper { 7 | private current = -1; 8 | constructor( 9 | private stream: NodeJS.WriteStream, 10 | private pattern: string, 11 | private maxTicks: number, 12 | ) { 13 | this.tick(); 14 | } 15 | 16 | public tick(description?: string) { 17 | this.current++; 18 | this.line(); 19 | this.stream.write( 20 | this.pattern 21 | .replace(/:max/g, this.maxTicks.toString()) 22 | .replace(/:count/g, this.current.toString()), 23 | ); 24 | if (description) { 25 | this.stream.write(` (${description})`); 26 | } 27 | } 28 | 29 | public terminate() { 30 | this.line(); 31 | } 32 | 33 | private line() { 34 | if (this.stream.isTTY) { 35 | this.stream.clearLine(0); 36 | this.stream.cursorTo(0); 37 | } else { 38 | this.stream.write(os.EOL); 39 | } 40 | } 41 | } 42 | 43 | export function progress( 44 | installer: LocalInstaller, 45 | stream: WriteStream = process.stdout, 46 | ): void { 47 | let progressKeeper: ProgressKeeper; 48 | installer.on( 49 | 'packing_start', 50 | (_) => 51 | (progressKeeper = new ProgressKeeper( 52 | stream, 53 | '[install-local] packing - :count/:max', 54 | _.length, 55 | )), 56 | ); 57 | installer.on('packed', (pkg) => progressKeeper.tick(path.basename(pkg))); 58 | installer.on('packing_end', () => progressKeeper.terminate()); 59 | installer.on('install_start', (toInstall) => { 60 | const installPhrase = Object.keys(toInstall) 61 | .map((_) => path.basename(_)) 62 | .join(', '); 63 | if (installPhrase.length) { 64 | stream.write(`[install-local] installing into ${installPhrase}${os.EOL}`); 65 | } else { 66 | stream.write(`[install-local] nothing to install${os.EOL}`); 67 | } 68 | }); 69 | installer.on('installed', (pkg, stdout, stderr) => { 70 | stream.write(`[install-local] ${pkg} installed${os.EOL}`); 71 | stream.write(stdout); 72 | stream.write(stderr); 73 | stream.write(os.EOL); 74 | }); 75 | installer.on('install_end', () => 76 | stream.write(`[install-local] Done${os.EOL}`), 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/save.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import path from 'path'; 3 | import { Dependencies, InstallTarget, Options, Package } from './index'; 4 | 5 | export async function saveIfNeeded( 6 | targets: InstallTarget[], 7 | options: Options, 8 | ): Promise { 9 | if (options.save) { 10 | await Promise.all(targets.map(save)); 11 | } 12 | } 13 | 14 | async function save(target: InstallTarget) { 15 | const dependencies = 16 | target.packageJson.localDependencies || 17 | (target.packageJson.localDependencies = {}); 18 | const dependenciesBefore = Object.assign({}, dependencies); 19 | target.sources 20 | .sort((a, b) => a.directory.localeCompare(b.directory)) 21 | .forEach( 22 | (source) => 23 | (dependencies[source.packageJson.name] = path 24 | .relative(target.directory, source.directory) 25 | .replace(/\\/g, '/')), 26 | ); 27 | if (!equals(dependencies, dependenciesBefore)) { 28 | await savePackageJson(target); 29 | } 30 | } 31 | 32 | async function savePackageJson(target: Package) { 33 | await fs.writeFile( 34 | path.resolve(target.directory, 'package.json'), 35 | JSON.stringify(target.packageJson, undefined, 2), 36 | { encoding: 'utf8' }, 37 | ); 38 | } 39 | 40 | function equals(a: Dependencies, b: Dependencies) { 41 | const aNames = sortedNames(a); 42 | const bNames = sortedNames(b); 43 | if (aNames.length === bNames.length) { 44 | while (aNames.length) { 45 | if (!equalDependency(aNames.pop(), bNames.pop(), a, b)) { 46 | return false; 47 | } 48 | } 49 | return true; 50 | } 51 | return false; 52 | } 53 | 54 | function equalDependency( 55 | aKey: string | undefined, 56 | bKey: string | undefined, 57 | aDeps: Dependencies, 58 | bDeps: Dependencies, 59 | ) { 60 | return aKey === bKey && aKey && bKey && aDeps[aKey] === bDeps[bKey]; 61 | } 62 | 63 | function sortedNames(subject: Dependencies) { 64 | return Object.keys(subject).sort(); 65 | } 66 | -------------------------------------------------------------------------------- /src/siblingInstall.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | import path from 'path'; 3 | import { readPackageJson } from './helpers'; 4 | import { ListByPackage, LocalInstaller, Package, progress } from './index'; 5 | 6 | function filterTruthy(values: Array): Package[] { 7 | return values.filter((v) => v) as Package[]; 8 | } 9 | 10 | function readSiblingTargets() { 11 | const currentDirectoryName = path.basename(process.cwd()); 12 | return fs 13 | .readdir('..') 14 | .then((dirs) => dirs.filter((dir) => dir !== currentDirectoryName)) 15 | .then((dirs) => dirs.map((dir) => path.resolve('..', dir))) 16 | .then((dirs) => 17 | Promise.all( 18 | dirs.map((directory) => 19 | readPackageJson(directory) 20 | .then((packageJson) => ({ directory, packageJson })) 21 | .catch(() => null), 22 | ), 23 | ), 24 | ) 25 | .then(filterTruthy); 26 | } 27 | 28 | function siblingTargetsCurrent(siblingPackage: Package): boolean { 29 | const currentDirectory = path.resolve('.'); 30 | return Object.values(siblingPackage.packageJson.localDependencies ?? {}).some( 31 | (localDependencyPath) => 32 | path.resolve(localDependencyPath) === currentDirectory, 33 | ); 34 | } 35 | 36 | export function siblingInstall(): Promise { 37 | return readSiblingTargets() 38 | .then((siblings) => siblings.filter(siblingTargetsCurrent)) 39 | .then((targets) => { 40 | const sourceByTarget: ListByPackage = {}; 41 | targets.forEach((target) => (sourceByTarget[target.directory] = ['.'])); 42 | const installer = new LocalInstaller(sourceByTarget); 43 | progress(installer); 44 | return installer.install(); 45 | }) 46 | .then(() => void 0); 47 | } 48 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.settings.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/src" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import execa, { ExecaReturnValue } from 'execa'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | import rimraf from 'rimraf'; 5 | import uniqid from 'uniqid'; 6 | 7 | export function del(filename: string): Promise { 8 | return new Promise((resolve, reject) => 9 | rimraf(filename, (err) => { 10 | if (err) { 11 | reject(err); 12 | } else { 13 | resolve(); 14 | } 15 | }), 16 | ); 17 | } 18 | 19 | export function getRandomTmpDir(prefix: string): string { 20 | return path.resolve(os.tmpdir(), uniqid(prefix)); 21 | } 22 | 23 | export function exec( 24 | file: string, 25 | args?: readonly string[] | undefined, 26 | options?: execa.Options | undefined, 27 | ): Promise> { 28 | return execa(file, args, options); 29 | } 30 | -------------------------------------------------------------------------------- /stryker.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@stryker-mutator/core/schema/stryker-schema.json", 3 | "testRunner": "mocha", 4 | "reporters": [ 5 | "html", 6 | "progress", 7 | "dashboard" 8 | ], 9 | "checkers": [ 10 | "typescript" 11 | ], 12 | "buildCommand": "npm run build", 13 | "mochaOptions": { 14 | "spec": [ 15 | "dist/test/unit/**/*.js" 16 | ] 17 | }, 18 | "coverageAnalysis": "perTest", 19 | "thresholds": { 20 | "high": 90, 21 | "low": 80, 22 | "break": 75 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/helpers/producers.ts: -------------------------------------------------------------------------------- 1 | import { Options, PackageJson } from './../../src/index'; 2 | 3 | export function options(overrides?: Partial): Options { 4 | const defaults = new Options([]); 5 | return Object.assign({}, defaults, overrides); 6 | } 7 | 8 | export function packageJson(overrides?: Partial): PackageJson { 9 | return { 10 | name: 'name', 11 | version: '0.0.1', 12 | ...overrides, 13 | }; 14 | } 15 | -------------------------------------------------------------------------------- /test/integration/cli.it.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import execa from 'execa'; 3 | import { promises as fs } from 'fs'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | import rimraf from 'rimraf'; 7 | import { Package } from '../../src/index'; 8 | import { PackageJson } from './../../src/index'; 9 | 10 | const installLocal = path.resolve('bin', 'install-local'); 11 | 12 | const tmpDir = path.resolve(os.tmpdir(), 'local-installer-it'); 13 | const tmpFolder = (name: string) => path.resolve(tmpDir, name); 14 | 15 | describe('install-local cli given 3 packages', () => { 16 | let packages: { 17 | one: PackageHelper; 18 | two: PackageHelper; 19 | three: PackageHelper; 20 | }; 21 | 22 | beforeEach(async () => { 23 | packages = { 24 | one: new PackageHelper('one'), 25 | two: new PackageHelper('two'), 26 | three: new PackageHelper('three'), 27 | }; 28 | await rm(tmpDir); 29 | await fs.mkdir(tmpDir); 30 | await Promise.all([ 31 | packages.one.writePackage(), 32 | packages.two.writePackage(), 33 | packages.three.writePackage(), 34 | ]); 35 | }); 36 | 37 | it('should install 2 packages without changing the package.json', async () => { 38 | const cmd = `node ${installLocal} ${packages.two.directory} ${packages.three.directory}`; 39 | await execa.command(cmd, { cwd: packages.one.directory }); 40 | const installed = await packages.one.readdir('node_modules'); 41 | const packageJson = await packages.one.readFile('package.json'); 42 | expect(installed.sort()).to.deep.eq(['three', 'two']); 43 | expect(JSON.parse(packageJson)).to.deep.eq(packages.one.packageJson); 44 | }); 45 | 46 | it('should install 2 packages and update the package.json if -S is provided', async () => { 47 | const cmd = `node ${installLocal} -S ${packages.two.directory} ${packages.three.directory}`; 48 | const expectedPackageJson = Object.assign( 49 | { localDependencies: { three: '../three', two: '../two' } }, 50 | packages.one.packageJson, 51 | ); 52 | await execa.command(cmd, { cwd: packages.one.directory }); 53 | const installed = await packages.one.readdir('node_modules'); 54 | const packageJson = await packages.one.readFile('package.json'); 55 | expect(installed.sort()).to.deep.eq(['three', 'two']); 56 | expect(JSON.parse(packageJson)).to.deep.eq(expectedPackageJson); 57 | }); 58 | 59 | it('should install a package if it is in the "localDependencies" and no arguments are provided', async () => { 60 | packages.one.packageJson.localDependencies = { 61 | two: '../two', 62 | }; 63 | await packages.one.writePackage(); 64 | await execa.command(`node ${installLocal}`, { 65 | cwd: packages.one.directory, 66 | }); 67 | const installed = await packages.one.readdir('node_modules'); 68 | expect(installed).to.deep.eq(['two']); 69 | }); 70 | 71 | it('should install into siblings if --target-siblings is given', async () => { 72 | packages.one.packageJson.localDependencies = { 73 | two: '../two', 74 | }; 75 | await packages.one.writePackage(); 76 | await execa.command(`node ${installLocal} --target-siblings`, { 77 | cwd: packages.two.directory, 78 | }); 79 | const installed = await packages.one.readdir('node_modules'); 80 | expect(installed).to.deep.eq(['two']); 81 | }); 82 | 83 | it('should also work for scoped packages (https://github.com/nicojs/node-install-local/issues/1)', async () => { 84 | packages.one.packageJson.localDependencies = { 85 | two: '../two', 86 | }; 87 | packages.two.packageJson.name = '@scoped/two'; 88 | await Promise.all([ 89 | packages.one.writePackage(), 90 | packages.two.writePackage(), 91 | ]); 92 | await execa.command(`node ${installLocal}`, { 93 | cwd: packages.one.directory, 94 | }); 95 | }); 96 | 97 | it('should not install additional (dev) dependencies (https://github.com/nicojs/node-install-local/issues/23)', async () => { 98 | // Arrange 99 | packages.one.packageJson.localDependencies = { 100 | two: '../two', 101 | }; 102 | packages.one.packageJson.devDependencies = { 103 | typescript: '4.0.3', 104 | }; 105 | packages.one.packageJson.dependencies = { 106 | 'typed-inject': '3.0.0', 107 | }; 108 | packages.one.packageLock = { 109 | name: 'one', 110 | version: '0.0.0', 111 | lockfileVersion: 1, 112 | requires: true, 113 | dependencies: { 114 | 'typed-inject': { 115 | version: '3.0.0', 116 | resolved: 117 | 'https://registry.npmjs.org/typed-inject/-/typed-inject-3.0.0.tgz', 118 | integrity: 119 | 'sha512-LDuyPsk6mO1R0qpe/rm/4u/6pPgT2Fob5T+u2D/wDlORxqlwtG9oWxruTaFZ6L61kzwWGzSp80soc3UUScHmaQ==', 120 | }, 121 | typescript: { 122 | version: '4.0.3', 123 | resolved: 124 | 'https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz', 125 | integrity: 126 | 'sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg==', 127 | dev: true, 128 | }, 129 | }, 130 | }; 131 | await packages.one.writePackage(); 132 | 133 | // Act 134 | await execa.command(`node ${installLocal}`, { 135 | cwd: packages.one.directory, 136 | }); 137 | 138 | // Assert 139 | const installed = await packages.one.readdir('node_modules'); 140 | expect(installed).not.include('typescript'); 141 | expect(installed).not.include('typed-inject'); 142 | }); 143 | }); 144 | 145 | const rm = (directory: string) => 146 | new Promise((res, rej) => 147 | rimraf(directory, (err) => { 148 | if (err) { 149 | rej(err); 150 | } else { 151 | res(); 152 | } 153 | }), 154 | ); 155 | 156 | class PackageHelper implements Package { 157 | public directory: string; 158 | public packageJson: PackageJson; 159 | public packageLock: Record | undefined; 160 | constructor(private name: string) { 161 | this.directory = tmpFolder(name); 162 | this.packageJson = { 163 | name, 164 | version: '0.0.0', 165 | }; 166 | } 167 | 168 | public readdir(dir: string) { 169 | return fs.readdir(path.resolve(this.directory, dir)); 170 | } 171 | 172 | public readFile(file: string) { 173 | return fs.readFile(path.resolve(this.directory, file), 'utf8'); 174 | } 175 | 176 | public async writePackage() { 177 | await rm(this.directory); 178 | await fs.mkdir(this.directory); 179 | return await Promise.all([ 180 | fs.writeFile( 181 | path.resolve(this.directory, 'package.json'), 182 | JSON.stringify(this.packageJson, null, 2), 183 | 'utf8', 184 | ), 185 | fs.writeFile(path.resolve(this.directory, this.name), '', 'utf8'), 186 | this.packageLock 187 | ? fs.writeFile( 188 | path.resolve(this.directory, 'package-lock.json'), 189 | JSON.stringify(this.packageLock, null, 2), 190 | 'utf-8', 191 | ) 192 | : Promise.resolve(), 193 | ]); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /test/integration/utils.it.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { promises as fs } from 'fs'; 3 | import os from 'os'; 4 | import path from 'path'; 5 | import * as utils from '../../src/utils'; 6 | 7 | describe('utils integration', () => { 8 | it('should be able to delete directories', async () => { 9 | const dir = path.resolve(os.tmpdir(), 'utils_integration'); 10 | await fs.mkdir(dir); 11 | await fs.writeFile( 12 | path.resolve(dir, 'file.js'), 13 | 'console.log("hello world")', 14 | 'utf8', 15 | ); 16 | await utils.del(dir); 17 | await expect(fs.access(dir)).rejected; 18 | }); 19 | 20 | it('should be able to delete a file', async () => { 21 | const file = path.resolve(os.tmpdir(), 'file.js'); 22 | await fs.writeFile(file, 'console.log("hello world")', 'utf8'); 23 | await utils.del(file); 24 | await expect(fs.access(file)).rejected; 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | import chai from 'chai'; 2 | import chaiAsPromised from 'chai-as-promised'; 3 | import sinon from 'sinon'; 4 | import sinonChai from 'sinon-chai'; 5 | 6 | chai.use(chaiAsPromised); 7 | chai.use(sinonChai); 8 | 9 | export const mochaHooks = { 10 | afterEach(): void { 11 | sinon.restore(); 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.settings.json", 3 | "compilerOptions": { 4 | "outDir": "../dist/test" 5 | }, 6 | "references": [{ "path": "../src" }] 7 | } 8 | -------------------------------------------------------------------------------- /test/unit/LocalInstallerSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import type { ExecaReturnValue } from 'execa'; 3 | import { promises as fs } from 'fs'; 4 | import os from 'os'; 5 | import { resolve } from 'path'; 6 | import sinon from 'sinon'; 7 | import * as utils from '../../src/utils'; 8 | import { LocalInstaller } from './../../src/LocalInstaller'; 9 | const TEN_MEGA_BYTE = 1024 * 1024 * 10; 10 | 11 | describe('LocalInstaller install', () => { 12 | class TestHelper { 13 | public execStub = sinon.stub(utils, 'exec'); 14 | public mkdirStub = sinon.stub(fs, 'mkdir'); 15 | public readFileStub = sinon.stub(fs, 'readFile'); 16 | public rimrafStub = sinon.stub(utils, 'del'); 17 | public getRandomTmpDirStub = sinon 18 | .stub(utils, 'getRandomTmpDir') 19 | .returns(tmpDir); 20 | } 21 | 22 | let sut: LocalInstaller; 23 | let helper: TestHelper; 24 | const tmpDir = resolve(os.tmpdir(), 'node-local-install-5a6s4df65asdas'); 25 | 26 | function createExecaResult( 27 | overrides?: Partial>, 28 | ): ExecaReturnValue { 29 | return { 30 | command: '', 31 | exitCode: 0, 32 | isCanceled: false, 33 | failed: false, 34 | killed: false, 35 | stderr: '', 36 | stdout: '', 37 | timedOut: false, 38 | ...overrides, 39 | }; 40 | } 41 | 42 | beforeEach(() => { 43 | helper = new TestHelper(); 44 | 45 | // Call callback 46 | helper.mkdirStub.resolves(); 47 | }); 48 | 49 | describe('with some normal packages', () => { 50 | beforeEach(() => { 51 | sut = new LocalInstaller({ '/a': ['b', 'c'], d: ['/e'] }); 52 | stubPackageJson({ '/a': 'a', b: 'b', c: 'c', d: 'd', '/e': 'e' }); 53 | helper.execStub.resolves( 54 | createExecaResult({ stdout: 'stdout', stderr: 'stderr' }), 55 | ); 56 | helper.rimrafStub.resolves(); 57 | }); 58 | 59 | it('should create a temporary directory', async () => { 60 | await sut.install(); 61 | 62 | expect(helper.getRandomTmpDirStub).calledWith('node-local-install-'); 63 | expect(helper.mkdirStub).calledWith(tmpDir); 64 | }); 65 | 66 | it('should pack correct packages', async () => { 67 | await sut.install(); 68 | expect(helper.execStub).calledWith('npm', ['pack', resolve('b')], { 69 | cwd: tmpDir, 70 | maxBuffer: TEN_MEGA_BYTE, 71 | }); 72 | expect(helper.execStub).calledWith('npm', ['pack', resolve('c')], { 73 | cwd: tmpDir, 74 | maxBuffer: TEN_MEGA_BYTE, 75 | }); 76 | expect(helper.execStub).calledWith('npm', ['pack', resolve('/e')], { 77 | cwd: tmpDir, 78 | maxBuffer: TEN_MEGA_BYTE, 79 | }); 80 | }); 81 | 82 | it('should install correct packages', async () => { 83 | await sut.install(); 84 | expect(helper.execStub).calledWith( 85 | 'npm', 86 | [ 87 | 'i', 88 | '--no-save', 89 | '--no-package-lock', 90 | tmp('b-0.0.1.tgz'), 91 | tmp('c-0.0.2.tgz'), 92 | ], 93 | { cwd: resolve('/a'), env: undefined, maxBuffer: TEN_MEGA_BYTE }, 94 | ); 95 | expect(helper.execStub).calledWith( 96 | 'npm', 97 | ['i', '--no-save', '--no-package-lock', tmp('e-0.0.4.tgz')], 98 | { cwd: resolve('d'), env: undefined, maxBuffer: TEN_MEGA_BYTE }, 99 | ); 100 | }); 101 | 102 | it('should emit all events', async () => { 103 | const installTargetsIdentified = sinon.spy(); 104 | const installStart = sinon.spy(); 105 | const installed = sinon.spy(); 106 | const packingStart = sinon.spy(); 107 | const packed = sinon.spy(); 108 | const installEnd = sinon.spy(); 109 | const packingEnd = sinon.spy(); 110 | sut.on('install_targets_identified', installTargetsIdentified); 111 | sut.on('install_start', installStart); 112 | sut.on('installed', installed); 113 | sut.on('packing_start', packingStart); 114 | sut.on('packed', packed); 115 | sut.on('packing_end', packingEnd); 116 | sut.on('install_end', installEnd); 117 | await sut.install(); 118 | expect(installTargetsIdentified).callCount(1); 119 | expect(installStart).callCount(1); 120 | expect(installed).callCount(2); 121 | expect(packingStart).callCount(1); 122 | expect(packed).callCount(3); 123 | expect(installEnd).callCount(1); 124 | expect(packingEnd).callCount(1); 125 | }); 126 | 127 | it('should remove the temporary directory', async () => { 128 | await sut.install(); 129 | 130 | expect(helper.rimrafStub).calledWith(tmpDir); 131 | }); 132 | }); 133 | 134 | describe('with scoped packages', () => { 135 | beforeEach(() => { 136 | sut = new LocalInstaller({ '/a': ['b'] }); 137 | stubPackageJson({ '/a': 'a', b: '@s/b' }); 138 | helper.execStub.resolves( 139 | createExecaResult({ stdout: 'stdout', stderr: 'stderr' }), 140 | ); 141 | helper.rimrafStub.resolves(); 142 | }); 143 | 144 | it('should install scoped packages', async () => { 145 | await sut.install(); 146 | expect(helper.execStub).calledWith('npm', [ 147 | 'i', 148 | '--no-save', 149 | '--no-package-lock', 150 | tmp('s-b-0.0.1.tgz'), 151 | ]); 152 | }); 153 | }); 154 | 155 | describe('with npmEnv', () => { 156 | const npmEnv = { test: 'test', dummy: 'dummy' }; 157 | beforeEach(() => { 158 | sut = new LocalInstaller({ '/a': ['b'] }, { npmEnv }); 159 | stubPackageJson({ '/a': 'a', b: 'b' }); 160 | helper.execStub.resolves( 161 | createExecaResult({ stdout: 'stdout', stderr: 'stderr' }), 162 | ); 163 | helper.rimrafStub.resolves(); 164 | }); 165 | 166 | it('should call npm with correct env vars', async () => { 167 | await sut.install(); 168 | expect(helper.execStub).calledWith( 169 | 'npm', 170 | ['i', '--no-save', '--no-package-lock', tmp('b-0.0.1.tgz')], 171 | { env: npmEnv, cwd: resolve('/a'), maxBuffer: TEN_MEGA_BYTE }, 172 | ); 173 | }); 174 | }); 175 | 176 | describe('when readFile errors', () => { 177 | it('should propagate the error', () => { 178 | helper.readFileStub.rejects(new Error('file error')); 179 | return expect(sut.install()).to.eventually.rejectedWith('file error'); 180 | }); 181 | }); 182 | 183 | describe('when packing errors', () => { 184 | beforeEach(() => { 185 | sut = new LocalInstaller({ '/a': ['b'] }, {}); 186 | stubPackageJson({ '/a': 'a', b: 'b' }); 187 | }); 188 | 189 | it('should propagate the error', () => { 190 | helper.execStub.rejects(new Error('error')); 191 | return expect(sut.install()).to.eventually.rejectedWith('error'); 192 | }); 193 | }); 194 | 195 | describe('when installing errors', () => { 196 | beforeEach(() => { 197 | sut = new LocalInstaller({ '/a': ['b'] }, {}); 198 | stubPackageJson({ '/a': 'a', b: 'b' }); 199 | stubPack('b'); 200 | }); 201 | 202 | it('should propagate the error', () => { 203 | helper.execStub.rejects(new Error('install err')); 204 | return expect(sut.install()).to.eventually.rejectedWith('install err'); 205 | }); 206 | }); 207 | 208 | const tmp = (file: string) => resolve(tmpDir, file); 209 | 210 | const stubPackageJson = (recipe: { [directory: string]: string }) => { 211 | Object.keys(recipe).forEach((directory, i) => { 212 | helper.readFileStub 213 | .withArgs(resolve(directory, 'package.json'), sinon.match.any) 214 | .resolves( 215 | JSON.stringify({ 216 | name: recipe[directory], 217 | version: `0.0.${i}`, 218 | }), 219 | ); 220 | }); 221 | }; 222 | 223 | const stubPack = (...directories: string[]) => { 224 | directories.forEach((directory) => { 225 | helper.execStub.withArgs(`npm pack ${resolve(directory)}`).resolves(); 226 | }); 227 | }; 228 | }); 229 | -------------------------------------------------------------------------------- /test/unit/OptionsSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Options } from '../../src/Options'; 3 | 4 | describe('Options', () => { 5 | it('should parse "node install-local --save --target-siblings some dependencies"', () => { 6 | const options = new Options([ 7 | 'node', 8 | 'install-local', 9 | '--save', 10 | '--target-siblings', 11 | 'some', 12 | 'dependencies', 13 | ]); 14 | expect(options.save).to.be.ok; 15 | expect(options.targetSiblings).to.be.ok; 16 | expect(options.dependencies).to.deep.eq(['some', 'dependencies']); 17 | }); 18 | 19 | it('should parse "node install-local -S -T some dependencies"', () => { 20 | const options = new Options([ 21 | 'node', 22 | 'install-local', 23 | '-S', 24 | '-T', 25 | 'some', 26 | 'dependencies', 27 | ]); 28 | expect(options.save).to.be.ok; 29 | expect(options.targetSiblings).to.be.ok; 30 | expect(options.dependencies).to.deep.eq(['some', 'dependencies']); 31 | }); 32 | 33 | it('should reject when validating with --save and --target-siblings', () => { 34 | const options = new Options(['node', 'install-local', '-S', '-T']); 35 | return expect(options.validate()).rejectedWith( 36 | 'Invalid use of option --target-siblings. Cannot be used together with --save', 37 | ); 38 | }); 39 | 40 | it('should reject when validating with --target-siblings and dependencies', () => { 41 | const options = new Options([ 42 | 'node', 43 | 'install-local', 44 | '-T', 45 | 'some', 46 | 'dependencies', 47 | ]); 48 | return expect(options.validate()).rejectedWith( 49 | 'Invalid use of option --target-siblings. Cannot be used together with a dependency list', 50 | ); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /test/unit/cliSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import sinon from 'sinon'; 3 | import { cli } from '../../src/cli'; 4 | import * as siblingInstallModule from '../../src/siblingInstall'; 5 | import * as optionsModule from '../../src/Options'; 6 | import * as currentDirectoryInstallModule from '../../src/currentDirectoryInstall'; 7 | 8 | describe('cli', () => { 9 | let optionsMock: { 10 | dependencies: string[]; 11 | save: boolean; 12 | targetSiblings: boolean; 13 | validate: sinon.SinonStub; 14 | }; 15 | let currentDirectoryInstallStub: sinon.SinonStub< 16 | [optionsModule.Options], 17 | Promise 18 | >; 19 | let siblingInstallStub: sinon.SinonStub<[], Promise>; 20 | 21 | beforeEach(() => { 22 | optionsMock = { 23 | dependencies: [], 24 | save: false, 25 | targetSiblings: false, 26 | validate: sinon.stub(), 27 | }; 28 | sinon.stub(optionsModule, 'Options').returns(optionsMock); 29 | currentDirectoryInstallStub = sinon.stub( 30 | currentDirectoryInstallModule, 31 | 'currentDirectoryInstall', 32 | ); 33 | siblingInstallStub = sinon.stub(siblingInstallModule, 'siblingInstall'); 34 | }); 35 | 36 | describe('given a valid config', () => { 37 | beforeEach(() => { 38 | optionsMock.validate.resolves(); 39 | }); 40 | 41 | it('should install into current directory if targetSiblings = false', async () => { 42 | optionsMock.targetSiblings = false; 43 | await cli([]); 44 | expect(currentDirectoryInstallStub).to.have.been.called; 45 | expect(siblingInstallStub).to.not.have.been.called; 46 | }); 47 | 48 | it('should target siblings if targetSiblings = true', async () => { 49 | optionsMock.targetSiblings = true; 50 | await cli([]); 51 | expect(currentDirectoryInstallStub).to.not.have.been.called; 52 | expect(siblingInstallStub).to.have.been.called; 53 | }); 54 | }); 55 | 56 | describe('with an invalid config', () => { 57 | it('should reject', () => { 58 | optionsMock.validate.rejects(new Error('something is wrong')); 59 | return expect(cli([])).to.be.rejectedWith('something is wrong'); 60 | }); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /test/unit/currentDirectoryInstallSpec.ts: -------------------------------------------------------------------------------- 1 | import type { WriteStream } from 'tty'; 2 | import { expect } from 'chai'; 3 | import sinon from 'sinon'; 4 | import { currentDirectoryInstall } from '../../src/currentDirectoryInstall'; 5 | import * as helpers from '../../src/helpers'; 6 | import * as localInstallerModule from '../../src/LocalInstaller'; 7 | import * as progressModule from '../../src/progress'; 8 | import * as saveModule from '../../src/save'; 9 | import { options, packageJson } from '../helpers/producers'; 10 | import { InstallTarget, Options, PackageJson } from '../../src'; 11 | 12 | describe('currentDirectoryInstall', () => { 13 | let localInstallerStub: { install: sinon.SinonStub }; 14 | let progressStub: sinon.SinonStub< 15 | [localInstallerModule.LocalInstaller, WriteStream?], 16 | void 17 | >; 18 | let saveIfNeededStub: sinon.SinonStub< 19 | [InstallTarget[], Options], 20 | Promise 21 | >; 22 | let readPackageJsonStub: sinon.SinonStub<[string], Promise>; 23 | 24 | beforeEach(() => { 25 | localInstallerStub = { install: sinon.stub() }; 26 | sinon 27 | .stub(localInstallerModule, 'LocalInstaller') 28 | .returns(localInstallerStub); 29 | saveIfNeededStub = sinon.stub(saveModule, 'saveIfNeeded'); 30 | progressStub = sinon.stub(progressModule, 'progress'); 31 | readPackageJsonStub = sinon.stub(helpers, 'readPackageJson'); 32 | }); 33 | 34 | it('should install the local dependencies if none were provided', async () => { 35 | readPackageJsonStub.resolves( 36 | packageJson({ localDependencies: { a: '../a', b: '../b' } }), 37 | ); 38 | const expectedOptions = options({ dependencies: [] }); 39 | const expectedTargets: InstallTarget[] = [ 40 | { directory: '../a', packageJson: packageJson(), sources: [] }, 41 | ]; 42 | localInstallerStub.install.resolves(expectedTargets); 43 | await currentDirectoryInstall(expectedOptions); 44 | expect(localInstallerModule.LocalInstaller).calledWith({ 45 | '.': ['../a', '../b'], 46 | }); 47 | expect(localInstallerModule.LocalInstaller).calledWithNew; 48 | expect(localInstallerStub.install).called; 49 | expect(progressStub).to.have.been.calledWith(localInstallerStub); 50 | expect(readPackageJsonStub).to.have.been.calledWith('.'); 51 | expect(saveIfNeededStub).to.have.been.calledWith( 52 | expectedTargets, 53 | expectedOptions, 54 | ); 55 | }); 56 | 57 | it('should install given dependencies', async () => { 58 | localInstallerStub.install.resolves(); 59 | await currentDirectoryInstall(options({ dependencies: ['a', 'b'] })); 60 | expect(readPackageJsonStub).not.called; 61 | expect(localInstallerModule.LocalInstaller).calledWith({ '.': ['a', 'b'] }); 62 | expect(localInstallerStub.install).called; 63 | }); 64 | 65 | it('should reject if install rejects', () => { 66 | readPackageJsonStub.resolves(packageJson()); 67 | localInstallerStub.install.rejects(new Error('some error')); 68 | expect(currentDirectoryInstall(options())).to.rejectedWith('some error'); 69 | }); 70 | 71 | it('should not install anything when no arguments nor local dependencies are provided', async () => { 72 | localInstallerStub.install.resolves([]); 73 | readPackageJsonStub.resolves(packageJson({})); 74 | const expectedOptions = options({ dependencies: [] }); 75 | await currentDirectoryInstall(expectedOptions); 76 | expect(localInstallerModule.LocalInstaller).calledWith({ '.': [] }); 77 | expect(localInstallerModule.LocalInstaller).calledWithNew; 78 | expect(localInstallerStub.install).called; 79 | expect(progressStub).to.have.been.calledWith(localInstallerStub); 80 | expect(readPackageJsonStub).to.have.been.calledWith('.'); 81 | expect(saveIfNeededStub).to.have.been.calledWith([], expectedOptions); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /test/unit/helpersSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { promises as fs } from 'fs'; 3 | import path from 'path'; 4 | import sinon from 'sinon'; 5 | import { readPackageJson } from '../../src/helpers'; 6 | 7 | describe('Helpers', () => { 8 | let readFileStub: sinon.SinonStub<[string, string], Promise>; 9 | 10 | beforeEach(() => { 11 | // @ts-expect-error picks the wrong overload 12 | readFileStub = sinon.stub(fs, 'readFile'); 13 | readFileStub.resolves('{}'); 14 | }); 15 | 16 | it('should call fs.readFile with the path and utf8 as arguments when readPackageJson is called', async () => { 17 | const pathToProject = '/test/path/to/project'; 18 | 19 | await readPackageJson(pathToProject); 20 | 21 | expect(readFileStub).calledWith( 22 | path.join(pathToProject, 'package.json'), 23 | 'utf8', 24 | ); 25 | }); 26 | 27 | it("should convert the content read to a javascript 'PackageJson' object", async () => { 28 | readFileStub.resolves('{ "key": "value" }'); 29 | 30 | const result = await readPackageJson('/test/path/to/project'); 31 | 32 | expect(result).to.deep.equal({ key: 'value' }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /test/unit/progressSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import os from 'os'; 3 | import sinon from 'sinon'; 4 | import { WriteStream } from 'tty'; 5 | import { progress } from '../../src/progress'; 6 | import { LocalInstaller } from './../../src/LocalInstaller'; 7 | 8 | describe('progress', () => { 9 | let eventEmitter: LocalInstaller; 10 | let streamStub: WriteStream; 11 | 12 | beforeEach(() => { 13 | streamStub = stubStdOut(); 14 | eventEmitter = new LocalInstaller({}); 15 | progress(eventEmitter, streamStub); 16 | }); 17 | 18 | describe('on "install_targets_identified" with 2 install targets', () => { 19 | beforeEach(() => { 20 | const packageB = createPackage('b'); 21 | const packageC = createPackage('c'); 22 | const packageF = createPackage('f'); 23 | eventEmitter.emit('install_targets_identified', [ 24 | { 25 | directory: 'a', 26 | packageJson: { name: 'a', version: '0.0.1' }, 27 | sources: [packageB, packageC], 28 | }, 29 | { 30 | directory: 'e', 31 | packageJson: { name: 'e', version: 'c' }, 32 | sources: [packageB, packageF], 33 | }, 34 | ]); 35 | }); 36 | it('should tick on "packing_start"', () => { 37 | eventEmitter.emit('packing_start', ['a', 'b']); 38 | expect(streamStub.write).to.have.been.calledWith( 39 | '[install-local] packing - 0/2', 40 | ); 41 | }); 42 | 43 | it('should tick on "packed"', () => { 44 | eventEmitter.emit('packing_start', ['a', 'b']); 45 | eventEmitter.emit('packed', 'a'); 46 | expect(streamStub.clearLine).to.have.been.called; 47 | expect(streamStub.cursorTo).to.have.been.calledWith(0); 48 | expect(streamStub.write).to.have.been.calledWith( 49 | '[install-local] packing - 1/2', 50 | ); 51 | expect(streamStub.write).to.have.been.calledWith(' (a)'); 52 | }); 53 | 54 | it('should not clear line when not a TTY on "packed"', () => { 55 | streamStub.isTTY = false; 56 | eventEmitter.emit('packing_start', ['a', 'b']); 57 | eventEmitter.emit('packed', 'a'); 58 | expect(streamStub.clearLine).to.not.have.been.called; 59 | expect(streamStub.cursorTo).to.not.have.been.called; 60 | expect(streamStub.write).to.have.been.calledWith(os.EOL); 61 | }); 62 | 63 | it('should not tick on "packing_end"', () => { 64 | eventEmitter.emit('packing_start', ['a', 'b']); 65 | eventEmitter.emit('packing_end'); 66 | expect(streamStub.clearLine).to.have.been.called; 67 | expect(streamStub.cursorTo).to.have.been.calledWith(0); 68 | }); 69 | 70 | it('should tick on "install_start"', () => { 71 | eventEmitter.emit('install_start', { a: ['b'], c: ['d'] }); 72 | expect(streamStub.write).to.have.been.calledWith( 73 | `[install-local] installing into a, c${os.EOL}`, 74 | ); 75 | }); 76 | 77 | it('should print that there is nothing todo on "install_start" without targets', () => { 78 | eventEmitter.emit('install_start', {}); 79 | expect(streamStub.write).to.have.been.calledWith( 80 | `[install-local] nothing to install${os.EOL}`, 81 | ); 82 | }); 83 | 84 | it('should tick on "installed"', () => { 85 | eventEmitter.emit('installed', 'a', 'stdout', 'stderr'); 86 | expect(streamStub.write).to.have.been.calledWith( 87 | `[install-local] a installed${os.EOL}`, 88 | ); 89 | expect(streamStub.write).to.have.been.calledWith('stdout'); 90 | expect(streamStub.write).to.have.been.calledWith('stderr'); 91 | }); 92 | 93 | it('should terminate on "install_end"', () => { 94 | eventEmitter.emit('install_end'); 95 | expect(streamStub.write).to.have.been.calledWith( 96 | `[install-local] Done${os.EOL}`, 97 | ); 98 | }); 99 | }); 100 | }); 101 | 102 | const createPackage = (name: string) => ({ 103 | directory: name, 104 | packageJson: { name, version: '0' }, 105 | }); 106 | 107 | const stubStdOut = (): WriteStream => ({ 108 | columns: 1000, 109 | // @ts-expect-error sinon limitation with overloads 110 | cursorTo: sinon.stub(), 111 | clearLine: sinon.stub(), 112 | eventNames: sinon.stub(), 113 | prependOnceListener: sinon.stub(), 114 | prependListener: sinon.stub(), 115 | listenerCount: sinon.stub(), 116 | emit: sinon.stub(), 117 | listeners: sinon.stub(), 118 | getMaxListeners: sinon.stub(), 119 | setMaxListeners: sinon.stub(), 120 | removeAllListeners: sinon.stub(), 121 | removeListener: sinon.stub(), 122 | once: sinon.stub(), 123 | on: sinon.stub(), 124 | addListener: sinon.stub(), 125 | isTTY: true, 126 | readable: false, 127 | writable: true, 128 | // @ts-expect-error sinon limitation with overloads 129 | write: sinon.stub(), 130 | // @ts-expect-error sinon limitation with overloads 131 | end: sinon.stub(), 132 | read: sinon.stub(), 133 | setEncoding: sinon.stub(), 134 | pause: sinon.stub(), 135 | resume: sinon.stub(), 136 | isPaused: sinon.stub(), 137 | pipe: sinon.stub(), 138 | unpipe: sinon.stub(), 139 | unshift: sinon.stub(), 140 | wrap: sinon.stub(), 141 | }); 142 | -------------------------------------------------------------------------------- /test/unit/saveSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { promises as fs } from 'fs'; 3 | import path from 'path'; 4 | import sinon from 'sinon'; 5 | import { saveIfNeeded } from '../../src/save'; 6 | import { InstallTarget } from './../../src/index'; 7 | import { Options } from './../../src/Options'; 8 | 9 | const sut = saveIfNeeded; 10 | 11 | describe('saveIfNeeded', () => { 12 | let writeFileStub: sinon.SinonStub; 13 | let input: InstallTarget[]; 14 | 15 | beforeEach(() => { 16 | input = [ 17 | { 18 | sources: [ 19 | { 20 | directory: 'a', 21 | packageJson: { 22 | name: 'a', 23 | version: '0.0.1', 24 | }, 25 | }, 26 | ], 27 | directory: 't', 28 | packageJson: { 29 | name: 't', 30 | version: '0.0.2', 31 | }, 32 | }, 33 | ]; 34 | writeFileStub = sinon.stub(fs, 'writeFile'); 35 | }); 36 | 37 | it('should not do anything when no option to save', async () => { 38 | await sut(input, new Options([])); 39 | expect(writeFileStub).to.not.have.been.called; 40 | }); 41 | 42 | describe('when --save is in the options', () => { 43 | it('should write "localDependencies" to package.json', async () => { 44 | const expectedContent = JSON.stringify( 45 | { name: 't', version: '0.0.2', localDependencies: { a: '../a' } }, 46 | null, 47 | 2, 48 | ); 49 | await sut(input, new Options(['node', 'install-local', '--save'])); 50 | expect(writeFileStub).to.have.been.calledWith( 51 | path.resolve(input[0].directory, 'package.json'), 52 | expectedContent, 53 | { encoding: 'utf8' }, 54 | ); 55 | expect(writeFileStub).to.have.been.calledOnce; 56 | }); 57 | 58 | it('should override any localDependency with the same name, and leave others be', async () => { 59 | const expectedContent = JSON.stringify( 60 | { 61 | name: 't', 62 | version: '0.0.2', 63 | localDependencies: { a: '../a', b: 'b' }, 64 | }, 65 | null, 66 | 2, 67 | ); 68 | input[0].packageJson.localDependencies = { a: '', b: 'b' }; 69 | await sut(input, new Options(['node', 'install-local', '--save'])); 70 | expect(writeFileStub).to.have.been.calledWith( 71 | path.resolve(input[0].directory, 'package.json'), 72 | expectedContent, 73 | { encoding: 'utf8' }, 74 | ); 75 | expect(writeFileStub).to.have.been.calledOnce; 76 | }); 77 | 78 | it('should not write anything if the desired state is already in "localDependencies"', async () => { 79 | input[0].packageJson.localDependencies = { a: '../a' }; 80 | await sut(input, new Options(['node', 'install-local', '--save'])); 81 | expect(writeFileStub).to.not.have.been.called; 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /test/unit/siblingInstallSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { PathLike, promises as fs } from 'fs'; 3 | import path from 'path'; 4 | import sinon from 'sinon'; 5 | import * as helpers from '../../src/helpers'; 6 | import { siblingInstall } from '../../src/siblingInstall'; 7 | import * as progressModule from '../../src/progress'; 8 | import * as localInstallerModule from '../../src/LocalInstaller'; 9 | import { PackageJson } from '../../src'; 10 | 11 | describe('siblingInstall', () => { 12 | let readdirStub: sinon.SinonStub<[PathLike], Promise>; 13 | let readPackageJson: sinon.SinonStub<[string], Promise>; 14 | let localInstallStub: { install: sinon.SinonStub }; 15 | 16 | beforeEach(() => { 17 | localInstallStub = { install: sinon.stub() }; 18 | // @ts-expect-error picks the wrong overload 19 | readdirStub = sinon.stub(fs, 'readdir'); 20 | readPackageJson = sinon.stub(helpers, 'readPackageJson'); 21 | sinon.stub(progressModule, 'progress'); 22 | sinon 23 | .stub(localInstallerModule, 'LocalInstaller') 24 | .returns(localInstallStub); 25 | }); 26 | 27 | it('should install packages from sibling dirs if they exist', async () => { 28 | // Arrange 29 | const currentDirName = path.basename(process.cwd()); 30 | readdirStub.resolves(['a', 'b', 'c', 'd']); 31 | const siblings = { 32 | a: path.resolve('..', 'a'), 33 | b: path.resolve('..', 'b'), 34 | c: path.resolve('..', 'c'), 35 | d: path.resolve('..', 'd'), 36 | }; 37 | readPackageJson 38 | .withArgs(siblings.a) 39 | .resolves( 40 | createPackageJson({ 41 | localDependencies: { someName: `../${currentDirName}` }, 42 | }), 43 | ) 44 | .withArgs(siblings.b) 45 | .rejects() 46 | .withArgs(siblings.c) 47 | .resolves( 48 | createPackageJson({ 49 | localDependencies: { someOtherName: process.cwd() }, 50 | }), 51 | ) 52 | .withArgs(siblings.d) 53 | .resolves( 54 | createPackageJson({ 55 | localDependencies: { someOtherName: 'some/other/localDep' }, 56 | }), 57 | ); 58 | localInstallStub.install.resolves(); 59 | 60 | // Act 61 | await siblingInstall(); 62 | 63 | // Assert 64 | expect(readdirStub).calledWith('..'); 65 | expect(localInstallerModule.LocalInstaller).calledWith({ 66 | [siblings.a]: ['.'], 67 | [siblings.c]: ['.'], 68 | }); 69 | expect(localInstallerModule.LocalInstaller).calledWithNew; 70 | expect(localInstallStub.install).called; 71 | expect(progressModule.progress).calledWith(localInstallStub); 72 | }); 73 | 74 | it('should reject when install rejects', () => { 75 | // Arrange 76 | readdirStub.resolves(['a']); 77 | readPackageJson.resolves( 78 | createPackageJson({ localDependencies: { b: process.cwd() } }), 79 | ); 80 | localInstallStub.install.rejects(new Error('some error')); 81 | return expect(siblingInstall()).rejectedWith('some error'); 82 | }); 83 | 84 | function createPackageJson(overrides?: Partial): PackageJson { 85 | return { 86 | name: 'a', 87 | version: '1.2.0', 88 | localDependencies: {}, 89 | ...overrides, 90 | }; 91 | } 92 | }); 93 | -------------------------------------------------------------------------------- /test/unit/utilSpec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | import { getRandomTmpDir } from '../../src/utils'; 5 | 6 | describe('Utils', () => { 7 | it('should return a random directory inside the OS tmp dir', () => { 8 | const prefix = 'some-prefix-'; 9 | const expectedPath = path.resolve(os.tmpdir(), prefix); 10 | 11 | // Match expected path followed by a unique id (replacing `\` with `\\`) 12 | const pathRegex = new RegExp(`^${expectedPath.replace(/\\/g, '\\\\')}.*`); 13 | expect(getRandomTmpDir(prefix)).to.match(pathRegex); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | {"path": "src"}, 5 | {"path": "test"} 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "declaration": true, 5 | "module": "commonjs", 6 | "outDir": "dist", 7 | "composite": true, 8 | "strict": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "sourceMap": true, 14 | "esModuleInterop": true, 15 | "declarationMap": true 16 | } 17 | } 18 | --------------------------------------------------------------------------------