├── .npmrc ├── .gitattributes ├── test ├── package.json └── main.spec.ts ├── bin ├── pwsh.cmd └── pwsh ├── npm_lifecycle_postinstall.js ├── .mocharc.js ├── tsconfig-watch.json ├── src ├── __root.ts ├── cache.ts ├── version-utils.ts ├── npm_lifecycle_postinstall.ts └── util.ts ├── .gitignore ├── .editorconfig ├── tsconfig.json ├── scripts ├── helpers.ps1 ├── parse-versions.ts └── build.ps1 ├── CONTRIBUTING.md ├── .vscode ├── tasks.json └── launch.json ├── .github ├── test.sh ├── test-windows.ps1 └── workflows │ └── continuous.yml ├── webpack.config.ts ├── CHANGELOG.md ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "", 4 | "version": "" 5 | } 6 | -------------------------------------------------------------------------------- /bin/pwsh.cmd: -------------------------------------------------------------------------------- 1 | echo "Installation should have replaced this file with a symlink. Something went wrong." 2 | exit 1 3 | -------------------------------------------------------------------------------- /npm_lifecycle_postinstall.js: -------------------------------------------------------------------------------- 1 | if(require('fs').existsSync(require('path').join(__dirname, 'dist'))) { 2 | require('./dist/npm_lifecycle_postinstall.js'); 3 | } 4 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'async-only': true, 3 | bail: true, 4 | extension: ['ts'], 5 | require: 'ts-node/register', 6 | timeout: 60e3 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig-watch.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/tsconfig", 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "watch": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /bin/pwsh: -------------------------------------------------------------------------------- 1 | # Installation should replace this file with a symlink to a real PowerShell binary. 2 | echo 'Installation should have replaced this file with a symlink. Something went wrong.' 1>&2 3 | exit 1 4 | -------------------------------------------------------------------------------- /src/__root.ts: -------------------------------------------------------------------------------- 1 | // expose root directory of module installation at runtime 2 | // Because using __dirname in webpack gets tricky 3 | import * as Path from 'path'; 4 | 5 | export const __root = Path.resolve(__dirname, '..'); 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | out 3 | dist 4 | src__defunct__ 5 | wsl-install 6 | win-install 7 | test-bin 8 | pwsh-*.tgz 9 | test/real 10 | test/prefix-link-* 11 | test/.npmrc 12 | test/shrinkwrap.yaml 13 | test/node_modules 14 | test/package-lock.json 15 | /.vscode 16 | /pwsh 17 | /packages 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = false 10 | insert_final_newline = true 11 | 12 | [{package.json,.github/**.yml}] 13 | indent_size = 2 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": true, 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": ["esnext"], 6 | "module": "commonjs", 7 | "rootDir": "src", 8 | "outDir": "out", 9 | "importHelpers": true, 10 | "sourceMap": true, 11 | "moduleResolution": "node" 12 | }, 13 | "include": [ 14 | "src/*" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /scripts/helpers.ps1: -------------------------------------------------------------------------------- 1 | function run($block) { 2 | $OldErrorActionPreference = $ErrorActionPreference 3 | $ErrorActionPreference = 'Continue' 4 | & $block 5 | $ErrorActionPreference = $OldErrorActionPreference 6 | if($LASTEXITCODE -ne 0) { throw "Non-zero exit code: $LASTEXITCODE" } 7 | } 8 | 9 | function readfile($path) { 10 | ,(get-content -raw -encoding utf8 -path $path) 11 | } 12 | function writefile { 13 | param( 14 | $path, 15 | [Parameter(valuefrompipeline)] $content 16 | ) 17 | [IO.File]::WriteAllText(($path | resolve-path), $content) 18 | } 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ./scripts/build.ps1 is our catch-all build and publish script. 2 | 3 | Tests are run via Pester. See ./test/main.test.ps1 4 | 5 | To compile and test: 6 | 7 | ```powershell 8 | ./scripts/build.ps1 -compile -package -test 9 | ``` 10 | 11 | This will run tests on Windows and Linux (via WSL). You'll need a copy of `pwsh` installed in both Windows and Linux. You should be able to do this via `npm install --global pwsh` 12 | 13 | Also make sure npm and node are installed in WSL and are on your PATH. If they're added via bashrc, you'll need to setup a `pwsh` $PROFILE to set the right PATH due to the way we invoke Linux `pwsh` to run the tests. We read your `pwsh` $PROFILE, not your bash profile. 14 | -------------------------------------------------------------------------------- /.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": "TypeScript Compile", 8 | "type": "typescript", 9 | "tsconfig": "tsconfig.json", 10 | "problemMatcher": [ 11 | "$tsc" 12 | ], 13 | "group": { 14 | "kind": "build", 15 | "isDefault": true 16 | } 17 | }, 18 | { 19 | "label": "TypeScript Watch", 20 | "type": "typescript", 21 | "tsconfig": "tsconfig-watch.json", 22 | "problemMatcher": [ 23 | "$tsc-watch" 24 | ], 25 | "group": "build" 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.github/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euxo pipefail 3 | 4 | # osx, linux, or windows 5 | os=$1 6 | if [ $os = osx ] ; then 7 | powershellUrl=https://github.com/PowerShell/PowerShell/releases/download/v7.0.3/PowerShell-7.0.3-osx-x64.tar.gz 8 | elif [ $os = linux ] ; then 9 | powershellUrl=https://github.com/PowerShell/PowerShell/releases/download/v7.0.3/powershell-7.0.3-linux-x64.tar.gz 10 | fi 11 | 12 | # Install pnpm 13 | sudo npm install -g pnpm 14 | 15 | # Grab PowerShell 16 | mkdir pwsh 17 | pushd pwsh 18 | wget --quiet --output-document=- $powershellUrl | tar -xvz 19 | popd 20 | # Put ~/bin on path; symlink pwsh into ~/bin 21 | export PATH=$HOME/bin:$PATH 22 | mkdir -p $HOME/bin 23 | ln -s $PWD/pwsh/pwsh ~/bin/pwsh 24 | 25 | # Install npm dependencies locally 26 | npm install 27 | 28 | # Create npm prefix symlink 29 | mkdir -p ./test/real/prefix-posix 30 | ln -s "$PWD/test/real/prefix-posix" "$PWD/test/prefix-link-posix" 31 | 32 | # Diagnostic logging 33 | export 34 | echo 'BINARY PATHS:' 35 | which node 36 | which npm 37 | which pnpm 38 | which pwsh 39 | 40 | pwsh -noprofile ./scripts/build.ps1 -compile -packageForTests -testPosix 41 | -------------------------------------------------------------------------------- /.github/test-windows.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = 'Stop' 2 | $VerbosePreference = 'Continue' 3 | 4 | . "$PSScriptRoot\..\scripts\helpers.ps1" 5 | 6 | $PowershellUrl = "https://github.com/PowerShell/PowerShell/releases/download/v7.0.3/PowerShell-7.0.3-win-x64.zip" 7 | 8 | Invoke-WebRequest "$PowershellUrl" -OutFile pwsh.zip 9 | Microsoft.PowerShell.Archive\Expand-Archive -Path pwsh.zip -DestinationPath pwsh -Force 10 | 11 | # Install pnpm; needed for some test cases 12 | run { npm install -g pnpm } 13 | 14 | # Install npm dependencies locally 15 | run { npm install } 16 | 17 | # Create npm prefix symlink 18 | new-item -type Directory -Path $PSScriptRoot/../test/real/prefix-windows 19 | new-item -type SymbolicLink -Path $PSScriptRoot/../test/prefix-link-windows -Target $PSScriptRoot/../test/real/prefix-windows 20 | 21 | $env:Path = "$(Get-Location)/pwsh;" + $env:Path 22 | 23 | [System.Environment]::GetEnvironmentVariables() 24 | write-host 'BINARY PATHS:' 25 | (get-command node).Path 26 | (get-command npm).Path 27 | (get-command pnpm).Path 28 | (get-command pwsh).Path 29 | 30 | # Run tests 31 | pwsh -executionpolicy remotesigned -noprofile .\scripts\build.ps1 -compile -packageForTests -testWindows -winPwsh "$(Get-Location)\pwsh\pwsh.exe" 32 | exit $LASTEXITCODE 33 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as Path from 'path'; 2 | import * as webpack from 'webpack'; 3 | 4 | const config: webpack.Configuration = { 5 | context: __dirname, 6 | entry: './out/npm_lifecycle_postinstall', 7 | output: { 8 | path: __dirname + '/dist', 9 | filename: 'npm_lifecycle_postinstall.js' 10 | }, 11 | target: 'node', 12 | mode: 'production', 13 | optimization: { 14 | minimize: false 15 | }, 16 | devtool: 'source-map', 17 | 18 | externals: [ 19 | function(context, request, callback) { 20 | const localExternals = [ 21 | './out/buildTags.json', 22 | './out/__root' 23 | ]; 24 | for(const localExternal of localExternals) { 25 | if(request[0] === '.' && Path.resolve(context, request) === Path.resolve(__dirname, localExternal)) { 26 | return callback(null, 'commonjs ' + request); 27 | } 28 | } 29 | callback(null, undefined); 30 | }, 31 | {tar: 'commonjs tar'} 32 | ], 33 | 34 | module: { 35 | rules: [{ 36 | test: /\.js$/, 37 | use: ['source-map-loader'] 38 | }] 39 | }, 40 | }; 41 | 42 | export = config; 43 | -------------------------------------------------------------------------------- /src/cache.ts: -------------------------------------------------------------------------------- 1 | import * as Path from 'path'; 2 | import * as os from 'os'; 3 | import { readJsonFileWithDefault, writeJsonFile, isGlobalInstall, getNpmGlobalNodeModules } from './util'; 4 | 5 | export interface CacheManifest { 6 | [name: string]: CacheManifestEntry; 7 | } 8 | 9 | export interface CacheManifestEntry { 10 | relativePathToBin: string; 11 | } 12 | 13 | /** 14 | * @param machine Reserved for future use, in case behavior differs based on OS or other environment attributes. 15 | * @returns absolute path to a global shared cache directory into which we can download and extract powershell versions. 16 | */ 17 | export function getCacheInstallDirectory(machine: Pick = process): string { 18 | if(isGlobalInstall) { 19 | return Path.join(getNpmGlobalNodeModules(), '@cspotcode/pwsh-cache'); 20 | // TODO should cache in NPM_PREFIX for --global installations 21 | } else { 22 | return Path.join(os.homedir(), '.npm-pwsh'); 23 | } 24 | } 25 | 26 | export function getCacheManifestPath() { 27 | return Path.join(getCacheInstallDirectory(), 'cache-manifest.json'); 28 | } 29 | 30 | export function readCacheManifest() { 31 | const manifest: CacheManifest = readJsonFileWithDefault(getCacheManifestPath(), {}); 32 | return manifest; 33 | } 34 | 35 | export function writeCacheManifest(manifest: CacheManifest) { 36 | writeJsonFile(getCacheManifestPath(), manifest, true); 37 | } 38 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Launch npm_lifecycle_postinstall.ts", 11 | "program": "${workspaceFolder}/src/npm_lifecycle_postinstall.ts", 12 | "protocol": "inspector", 13 | "runtimeArgs": [ 14 | "--require", "ts-node/register/transpile-only", "--nolazy" 15 | ], 16 | "console": "internalConsole", 17 | "internalConsoleOptions": "openOnSessionStart", 18 | "skipFiles": [ 19 | "node_modules/tslib/**", 20 | "/async_hooks.js", 21 | "/internal/inspector_async_hook.js", 22 | "/internal/**", 23 | "/**" 24 | ] 25 | }, 26 | { 27 | "type": "node", 28 | "request": "launch", 29 | "name": "Launch Program", 30 | "program": "${file}", 31 | "protocol": "inspector", 32 | "skipFiles": [ 33 | "node_modules/tslib/**", 34 | "/**" 35 | ] 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/continuous.yml: -------------------------------------------------------------------------------- 1 | # Requirements: 2 | # A build on Linux 3 | # A build on Mac 4 | # Package into a tarball 5 | # Install with latest version, check "powershell -version" 6 | # Install with requested older version, check "powershell -version" 7 | # Preinstall powershell (or a fake powershell binary), install with latest version, make sure it skips installation but exposed powershell on path 8 | # Preinstall powershell (or a fake powershell binary), install with requested older version, make sure it skips installation but exposed powershell on path 9 | 10 | # Matrix: 11 | # os: linux, mac 12 | # requested version: latest, beta7 13 | # fake powershell is preinstalled: true, false 14 | 15 | name: Continuous 16 | on: 17 | # master branch 18 | push: 19 | branches: 20 | - master 21 | # pull requests 22 | pull_request: {} 23 | 24 | jobs: 25 | testWindows: 26 | name: Test on Windows 27 | runs-on: windows-latest 28 | steps: 29 | - uses: actions/checkout@v2 30 | name: Checkout 31 | - name: Test 32 | run: pwsh -executionpolicy remotesigned -noprofile ./.github/test-windows.ps1 33 | testLinux: 34 | name: Test on Linux 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@v2 38 | name: Checkout 39 | - name: Test 40 | run: ./.github/test.sh linux 41 | testMac: 42 | name: Test on Mac 43 | runs-on: macos-latest 44 | steps: 45 | - uses: actions/checkout@v2 46 | name: Checkout 47 | - name: Test 48 | run: ./.github/test.sh osx 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # vNEXT 2 | 3 | * 4 | 5 | # v0.3.0 6 | 7 | * Move tests from Pester to mocha 8 | * Move from TravisCI to Github Actions 9 | * Tweak publishing workflow 10 | * Improve version parsing script to pull and parse all releases from Github's API. 11 | * Add many more pwsh versions 12 | * Add arm and arm64 pwsh packages 13 | 14 | # v0.2.0 15 | 16 | * Add Powershell 6.2.0-preview.1 17 | * Rename to `pwsh` (git repo `npm-pwsh`) 18 | * Fix compatibility with pnpm [#14](https://github.com/cspotcode/npm-pwsh/issues/14) 19 | 20 | # v0.1.1 21 | 22 | * Avoid repeated, unnecessary package extractions on Windows. [#11](https://github.com/cspotcode/npm-pwsh/issues/11) 23 | * Fix support for symlinked npm prefix. This affects users of nvs; possibly others. [#9](https://github.com/cspotcode/npm-pwsh/issues/9) 24 | 25 | # v0.1.0 26 | 27 | * Add PowerShell Core v6.1.0. 28 | * Remove "beta" header from README. 29 | * Mark v6.0.0-rc2 as prerelease. 30 | * Fix nvm compatibility when running WSL tests. 31 | 32 | # v0.0.8 33 | 34 | * Adds support for prerelease versions of pwsh, installable via `npm i pwsh@prerelease`. 35 | * Adds pwsh v6.1.0-rc.1. 36 | 37 | # v0.0.7 38 | 39 | * Adds automated tests. 40 | * Fix issue where npm would refuse to remove the installed symlinks / cmd shims when you `npm uninstall pwsh` 41 | * Fix problem `npm install`ing a fresh git clone (only affects contributors, not consumers) 42 | * Switch to cross-spawn; fixes bug globally installing on Windows. 43 | 44 | # v0.0.6 45 | 46 | * Fix broken 6.0.3 metadata. 47 | * Fix bug in --global installations; was not getting npm prefix correctly. 48 | 49 | # v0.0.5 50 | 51 | * Publishes tagged packages that install a specific version of PowerShell Core rather than the latest version. 52 | * Bundles via webpack to eliminate all npm dependencies and install faster. 53 | * `--global` installs cache in npm prefix; local installations still cache in $HOME 54 | 55 | # v0.0.4 56 | 57 | * I did not keep a changelog for this version and prior. 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pwsh", 3 | "version": "0.3.0", 4 | "description": "Install PowerShell Core via npm, allowing you to use it in npm scripts and node projects.", 5 | "bin": { 6 | "pwsh": "./bin/pwsh" 7 | }, 8 | "scripts": { 9 | "postinstall": "node ./npm_lifecycle_postinstall.js", 10 | "dump-env": "node -e console.dir(process.env)" 11 | }, 12 | "author": "Andrew Bradley ", 13 | "license": "MIT", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/cspotcode/npm-pwsh.git" 17 | }, 18 | "homepage": "https://github.com/cspotcode/npm-pwsh", 19 | "bugs": { 20 | "url": "https://github.com/cspotcode/npm-pwsh/issues" 21 | }, 22 | "keywords": [ 23 | "PowerShell", 24 | "pwsh", 25 | "scripts", 26 | "shell", 27 | "npm" 28 | ], 29 | "dependencies": { 30 | "tar": "^4.1.1" 31 | }, 32 | "devDependencies": { 33 | "@octokit/core": "^3.1.2", 34 | "@octokit/plugin-paginate-rest": "^2.3.0", 35 | "@octokit/plugin-rest-endpoint-methods": "^4.1.2", 36 | "@types/cross-spawn": "^6.0.0", 37 | "@types/get-stream": "^3.0.1", 38 | "@types/mkdirp": "^0.5.1", 39 | "@types/mocha": "^8.0.1", 40 | "@types/node": "^14.0.27", 41 | "@types/request": "^2.0.4", 42 | "@types/request-promise": "^4.1.38", 43 | "@types/rimraf": "^3.0.0", 44 | "@types/semver": "^7.3.1", 45 | "@types/tar": "^4.0.0", 46 | "@types/unzipper": "^0.8.4", 47 | "@types/webpack": "^4.4.9", 48 | "@types/which": "^1.3.1", 49 | "cmd-shim": "^2.0.2", 50 | "cross-spawn": "^6.0.5", 51 | "execa": "^4.0.3", 52 | "get-stream": "^3.0.0", 53 | "mkdirp": "^0.5.1", 54 | "mocha": "^8.1.1", 55 | "outdent": "^0.7.1", 56 | "path-key": "^3.1.1", 57 | "request": "^2.83.0", 58 | "request-promise": "^4.2.2", 59 | "rimraf": "^3.0.2", 60 | "semver": "^7.3.2", 61 | "source-map-loader": "^0.2.3", 62 | "ts-node": "^7.0.1", 63 | "tslib": "^1.9.3", 64 | "typescript": "^3.9.7", 65 | "unzipper": "^0.9.2", 66 | "webpack": "^4.16.5", 67 | "webpack-cli": "^3.1.0", 68 | "which": "^1.3.0" 69 | }, 70 | "files": [ 71 | "bin", 72 | "dist", 73 | "npm_lifecycle_postinstall.js" 74 | ] 75 | } 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pwsh 2 | 3 | Install PowerShell Core via npm, allowing you to use it in npm scripts and node projects. 4 | 5 | `npm i -g pwsh` may be the easiest way to get started with PowerShell Core on any platform. 6 | 7 | ## Why? 8 | 9 | I prefer PowerShell to bash for quickly writing npm scripts. (opinion) However, I can't expect collaborators to have it installed.* Adding `"pwsh"` as a `"devDependency"` solves that problem without any extra effort. 10 | 11 | We support both global and local npm installations, and we use a shared cache to avoid downloading duplicate copies of the full `pwsh` distribution. This means you can install us as a *local* dev dependency in dozens of projects, and the installation process will quickly create a symlink to the cache. 12 | 13 | *\* Even on Windows, the aging "Windows PowerShell" is preinstalled but we want to use `pwsh` / PowerShell Core, the cross-platform, more up-to-date edition of PowerShell.* 14 | 15 | ## Usage 16 | 17 | If you just want to use `pwsh` for your npm scripts, add us as a devDependency: 18 | 19 | ``` 20 | npm install --save-dev pwsh 21 | ``` 22 | 23 | If you want `pwsh` to be globally available as an interactive shell: 24 | 25 | ``` 26 | npm install --global pwsh 27 | ``` 28 | 29 | All installations are shared, so you can depend on "pwsh" in many projects without downloading multiple copies of `pwsh`. See the FAQ for details. 30 | 31 | ## Example 32 | 33 | ```json 34 | // Example package.json 35 | { 36 | "devDependencies": { 37 | // Use the latest pwsh to install pwsh 6.0.4 38 | "pwsh": "pwsh6.0.4" 39 | }, 40 | "scripts": { 41 | "test": "pwsh -NoProfile ./scripts/test.ps1" 42 | } 43 | } 44 | ``` 45 | 46 | ## FAQ 47 | 48 | ### Where is PowerShell installed? 49 | 50 | `--global` installations go into your npm prefix: 51 | 52 | * Linux and Mac: "\/lib/node_modules/@cspotcode/pwsh-cache" 53 | * Windows: "\/node_modules/@cspotcode/pwsh-cache" 54 | 55 | Local installations are cached in "$HOME/.npm-pwsh". We use your $HOME directory because Linux and Mac, by default, require root for global installations, so the npm 56 | prefix isn't writable. 57 | 58 | Installation is merely extracting the .zip or .tar.gz download from [PowerShell Core's Github releases](https://github.com/PowerShell/PowerShell/releases). No scripts are run; your system is not modified. 59 | 60 | ```bash 61 | # To view globally installed versions on Linux and Mac 62 | cd "$(npm get prefix)/lib/node_modules/@cspotcode/pwsh-cache" 63 | ls # shows all the versions installed 64 | ``` 65 | 66 | Installations are cached and shared, so if you work on 5 different projects that all depend 67 | on "pwsh", only a single copy of `pwsh` will be downloaded. Subsequent `npm install`s should be very fast, merely creating a symlink at "./node_modules/.bin/pwsh". 68 | 69 | PowerShell Core is about 50MB to download; 127MB extracted. 70 | 71 | ### How do I install a specific version of pwsh? 72 | 73 | By default we install the latest version of PowerShell Core. To install a specific version -- including prereleases -- check the [dist-tags](https://www.npmjs.com/package/pwsh?activeTab=versions) and install the one you want. 74 | 75 | ``` 76 | npm install pwsh@pwsh6.2.0-preview.1 77 | ``` 78 | 79 | *Remember, npm dist-tags !== npm versions. 80 | 81 | ### Dependencies 82 | 83 | We are not running `sudo apt-get`, `brew install`, etc. So it's possible PowerShell will complain about unmet dependencies that we're unable to provide. For context, checkout out [issue #8](https://github.com/cspotcode/npm-pwsh/issues/8). 84 | -------------------------------------------------------------------------------- /src/version-utils.ts: -------------------------------------------------------------------------------- 1 | import { versions } from './versions'; 2 | import { getStdout } from './util'; 3 | import * as assert from 'assert'; 4 | 5 | type Machine = Pick; 6 | 7 | export interface Version { 8 | version: string; 9 | /** 10 | * Output of `pwsh --version`, used to double-check that the ambient installed pwsh matches the expected version 11 | * 12 | * TODO this is never used, and the values we have for this are actually all wrong. 13 | * They have a `v` that shouldn't be there. 14 | */ 15 | versionOutput: string; 16 | isPrerelease: boolean; 17 | builds: ReadonlyArray>; 18 | } 19 | 20 | export type Extension = '.tar.gz' | '.zip' | '.unknown'; 21 | export type Arch = 'x64' | 'ia32' | 'arm' | 'arm64'; 22 | 23 | export interface Build { 24 | arch: Arch; 25 | platform: NodeJS.Platform; 26 | extension: Extension; 27 | url: string; 28 | /** MUST be uppercase */ 29 | sha256: string; 30 | /** relative path to pwsh executable within archive */ 31 | bin: string; 32 | } 33 | 34 | export interface VersionBuildPair { 35 | version: Version; 36 | build: Build; 37 | } 38 | 39 | /** 40 | * Returns the version and build of powershell we should install, or undefined if no know candidate is found. 41 | * @param pwshVersion A pwsh version number or 'latest' 42 | */ 43 | export function getBestBuild(pwshVersion: string, machine: Machine = process): VersionBuildPair { 44 | for(let v of versions) { 45 | if((pwshVersion === 'latest' && !v.isPrerelease) || pwshVersion === 'prerelease' || v.version === pwshVersion) { 46 | for(let b of v.builds) { 47 | if(b.arch === machine.arch && b.platform === machine.platform) { 48 | return {version: v, build: b}; 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * Directory name for a specific PowerShell build's installation. 57 | */ 58 | export function getDirnameForBuild({version, build}: VersionBuildPair) { 59 | return `powershell-${ version.version }-${ build.platform }-${ build.arch }`; 60 | } 61 | 62 | /** 63 | * Filename for a specific PowerShell build's download file, used to save tarball / zipfile into local cache. 64 | */ 65 | export function getFilenameForBuild(vb: VersionBuildPair) { 66 | return `${ getDirnameForBuild(vb) }${ vb.build.extension }`; 67 | } 68 | 69 | /** Invoke the ambient pwsh and get its $PSVersionTable */ 70 | export function getPSVersionTable(): PSVersionTable { 71 | const stdout = getStdout(['pwsh', '-noprofile', '-command', '$PSVersionTable | convertto-json']); 72 | return JSON.parse(stdout); 73 | } 74 | 75 | /** Structure of PowerShell's $PSVersionTable */ 76 | interface PSVersionTable { 77 | PSVersion: PSVersionTableVersion; 78 | PSEdition: 'Desktop' | 'Core'; 79 | PSCompatibleVersions: Array; 80 | BuildVersion: PSVersionTableVersion; 81 | CLRVersion: PSVersionTableVersion; 82 | WSManStackVersion: PSVersionTableVersion; 83 | PSRemotingProtocolVersion: PSVersionTableVersion; 84 | SerializationVersion: PSVersionTableVersion; 85 | } 86 | interface PSVersionTableVersion { 87 | Major: number; 88 | Minor: number; 89 | Build: number; 90 | Revision: number; 91 | MajorRevision: number; 92 | MinorRevision: number; 93 | } 94 | 95 | export const latestStableVersion = versions.find(v => !v.isPrerelease).version; 96 | export const latestIncludingPrereleaseVersion = versions[0].version; 97 | 98 | assert(latestStableVersion); 99 | -------------------------------------------------------------------------------- /src/npm_lifecycle_postinstall.ts: -------------------------------------------------------------------------------- 1 | import * as Path from 'path'; 2 | import {sync as mkdirpSync} from 'mkdirp'; 3 | import * as fs from 'fs'; 4 | import * as assert from 'assert'; 5 | import { 6 | sha256OfFile, 7 | downloadUrlToFile, 8 | readJsonFileWithDefault, 9 | patchJsonFile, 10 | getNpmBinDirectory, 11 | getNpmBinShimPath, 12 | TODO, 13 | pathsEquivalent, 14 | createSymlinkTo, 15 | extractArchive, 16 | buildTags 17 | } from "./util"; 18 | import * as which from 'which'; 19 | import { getBestBuild, getFilenameForBuild, getDirnameForBuild } from './version-utils'; 20 | import { getCacheInstallDirectory, readCacheManifest, getCacheManifestPath } from './cache'; 21 | import { __root } from './__root'; 22 | 23 | const log = console.log.bind(console); 24 | 25 | const intermediateLinkPath = Path.join(__root, 'bin', 'pwsh'); 26 | 27 | async function main() { 28 | // Find powershell on the PATH; maybe it's already installed. 29 | /** PATH to the powershell stub that this module installs via npm's "bin" capabilities */ 30 | const ownBinPath = getNpmBinShimPath('pwsh'); 31 | let foundPath: string | null; 32 | // Type assertion required until https://github.com/DefinitelyTyped/DefinitelyTyped/pull/22437 is merged 33 | for(foundPath of which.sync('pwsh', {nothrow: true, all: true}) || []) { 34 | // Skip the powershell command that this package puts on the path; that's not the one we want 35 | if(pathsEquivalent(foundPath, ownBinPath)) continue; // TODO if foundPath is a *symlink* to ownBinPath 36 | // TODO verify that it's the desired version of pwsh 37 | // TODO this is being totally ignored right now!! 38 | } 39 | // did not find it 40 | foundPath = null; 41 | if(foundPath) { 42 | log('Found pwsh on PATH; no action required.'); 43 | await createSymlinkTo({ 44 | linkPath: ownBinPath, 45 | targetPath: foundPath, 46 | intermediateLinkPath, 47 | log 48 | }); 49 | process.exit(0); 50 | } 51 | 52 | /** 53 | * Path to a shared directory where we store cached PowerShell installations. 54 | * 55 | * The goal is to cache the downloaded PowerShell archive in a shared location without forcing the user to install this module globally 56 | * or re-downloading powershell for every `npm install` of this package. 57 | */ 58 | const cacheInstallationDirectory = getCacheInstallDirectory(); 59 | 60 | mkdirpSync(cacheInstallationDirectory, {mode: 0o700}); 61 | 62 | /** Path to powershell executable */ 63 | let symlinkTarget: string; 64 | 65 | const {version, build} = getBestBuild(buildTags.pwshVersion); 66 | const cacheDirnameForBuild = getDirnameForBuild({version, build}); 67 | 68 | // Check if our expected Powershell version is already downloaded and installed in the cache. 69 | const manifest = readCacheManifest(); 70 | if(manifest[cacheDirnameForBuild]) { 71 | symlinkTarget = Path.resolve(cacheInstallationDirectory, cacheDirnameForBuild, manifest[cacheDirnameForBuild].relativePathToBin); 72 | try { 73 | // Assert that path both exists and is executable 74 | if(process.platform === 'win32') { 75 | assert(fs.statSync(symlinkTarget)); 76 | } else { 77 | assert(fs.statSync(symlinkTarget).mode & 0o100); 78 | } 79 | } catch(e) { 80 | symlinkTarget = undefined; 81 | } 82 | } 83 | 84 | /** Download the .tar.gz / .zip file to this path on disc */ 85 | const archiveDownloadPath = Path.join(cacheInstallationDirectory, getFilenameForBuild({version, build})); 86 | /** Extract .tar.gz / .zip into this directory */ 87 | const extractionTargetPath = Path.join(cacheInstallationDirectory, cacheDirnameForBuild); 88 | 89 | // If cached powershell installation not found, we must download and install 90 | if(!symlinkTarget) { 91 | 92 | // If downloaded archive already exists and is the same version 93 | if(fs.existsSync(archiveDownloadPath) && await sha256OfFile(archiveDownloadPath) === build.sha256) { 94 | log(`Found archive on disk; skipping download. (${ archiveDownloadPath })`); 95 | } else { 96 | // Download the archive 97 | log(`Downloading Powershell archive from ${ build.url } to ${ archiveDownloadPath }...`); 98 | await downloadUrlToFile(build.url, archiveDownloadPath); 99 | log('Download finished.'); 100 | const sha256 = await sha256OfFile(archiveDownloadPath); 101 | assert(await sha256 === build.sha256, `SHA256 verification failed; download appears corrupt. Expected ${ build.sha256 }, got ${ sha256 }`); 102 | } 103 | 104 | // Extract the archive. 105 | log(`Extracting archive to ${ extractionTargetPath }...`); 106 | mkdirpSync(extractionTargetPath); 107 | await extractArchive(build.extension, archiveDownloadPath, extractionTargetPath); 108 | log(`Extracted to ${ extractionTargetPath }`); 109 | 110 | symlinkTarget = Path.resolve(extractionTargetPath, build.bin); 111 | 112 | // Store the desired symlink target into our cache manifest 113 | patchJsonFile(getCacheManifestPath(), { 114 | indent: true, defaultValue: {} 115 | }, (json) => { 116 | json[cacheDirnameForBuild] = {relativePathToBin: build.bin}; 117 | }); 118 | } 119 | 120 | // Replace our stub with a symlink to the real powershell installation 121 | await createSymlinkTo({ 122 | linkPath: ownBinPath, 123 | targetPath: symlinkTarget, 124 | intermediateLinkPath, 125 | log 126 | }); 127 | 128 | log(`Done!`); 129 | } 130 | 131 | main().catch(err => { 132 | console.error(err); 133 | process.exit(1); 134 | }); 135 | -------------------------------------------------------------------------------- /scripts/parse-versions.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ts-node-script 2 | 3 | /* 4 | * This script queries Github's API for all PowerShell releases and parses them into the data structure which resides in 5 | * versions.ts 6 | */ 7 | 8 | import {sync as execSync} from 'execa'; 9 | import {Octokit} from '@octokit/core'; 10 | import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods"; 11 | import { paginateRest } from "@octokit/plugin-paginate-rest"; 12 | import {groupBy} from 'lodash'; 13 | import {Version, Build, Extension, Arch} from '../src/version-utils'; 14 | import { inspect } from 'util'; 15 | import { outdent } from 'outdent'; 16 | import { compare as compareSemver } from 'semver'; 17 | 18 | const MyOctokit = Octokit.plugin(restEndpointMethods, paginateRest); 19 | 20 | async function main() { 21 | const gitCreds = execSync('git', ['credential', 'fill'], { 22 | input: 'url=https://github.com', 23 | stdio: 'pipe' 24 | }).stdout; 25 | 26 | // Use `git credential fill` to grab a suitable access token. 27 | // If your local git client isn't setup this way, modify this script to hardcode an access token before running it. 28 | const gitAccessToken = gitCreds.split('\n').map(v => v.split('=')).find(v => v.length > 1 && v[0] === 'password')[1]; 29 | 30 | const githubClient = new MyOctokit({ 31 | auth: gitAccessToken 32 | }); 33 | 34 | const {data: releases} = await githubClient.repos.listReleases({ 35 | owner: 'PowerShell', 36 | repo: 'PowerShell', 37 | per_page: 100 38 | }); 39 | 40 | type File = typeof allFiles[number]; 41 | const allFiles = Array.from(parseAllFiles()); 42 | 43 | function* parseAllFiles() { 44 | for(const release of releases) { 45 | // Skip alphas because I don't think anyone will want to install them, and older ones do not include SHAs and have inconsistent formatting. 46 | if(release.name.includes('alpha')) continue; 47 | // Skip ancient v0.6.0 releases 48 | if(release.name.startsWith('v0')) continue; 49 | // Skip old 6.0.0 beta because they released different versions for windows 10, 8, and 7, and we don't want to deal with that 50 | if(release.name.startsWith('v6.0.0-beta')) continue; 51 | const match = release.body.match(/(?:### )?SHA256 Hashes of (?:the )?[Rr]elease [Aa]rtifacts:?\r?\n(?:\r?\n> .*?\r?\n)?([\s\S]+)/); 52 | if(!match) { 53 | console.dir(release.body); 54 | throw new Error(`Failed to parse ${ release.name }`); 55 | } 56 | const a = match[1]; 57 | // const match3 = a.match(/^\r\n>.*\r\n/); 58 | // console.dir(match3); 59 | // if(match3) a = a.slice(match3[0].length); 60 | for(const file of a.trim().split(/\n(?=- )/)) { 61 | const match = file.match(/- (.*)\r\n +- (.*)\r?/); 62 | if(!match) { 63 | throw new Error(`Failed to parse ${file}`); 64 | } 65 | const [, filename, sha256] = match; 66 | // Ignore irrelevant file extensions. 67 | if(filename.match(/\.(deb|rpm|AppImage|pkg|msi|msix|wixpdb)$/)) continue; 68 | // further parse the filename 69 | const match2 = filename.match(/[Pp]ower[Ss]hell-(.*?)-(win.*|osx|linux-alpine|linux-musl|linux)-(.*?)(\..*)/); 70 | if(!match2) { 71 | console.error('Skipping1:', filename); 72 | continue; 73 | } 74 | const [, version, platform, arch, extension] = match2; 75 | const url = `https://github.com/PowerShell/PowerShell/releases/download/v${ version }/${ filename }`; 76 | if(!['.tar.gz', '.zip'].includes(extension) || !['win', 'osx', 'linux'].includes(platform) || ['x64-fxdependent', 'fxdependent', 'fxdependentWinDesktop'].includes(arch)) { 77 | console.error('Skipping2:', version, platform, arch, extension); 78 | continue; 79 | } 80 | const bin = platform.startsWith('win') ? 'pwsh.exe' : 'pwsh'; 81 | yield { 82 | version, 83 | platform, 84 | arch, 85 | extension, 86 | sha256, 87 | url, 88 | bin 89 | }; 90 | } 91 | } 92 | } 93 | 94 | const grouped = groupBy(allFiles, 'version') as Record; 95 | // if has preview or rc in the name, then is a prerelease 96 | const versions: Version[] = Object.entries(grouped).map(([version, files]) => { 97 | return { 98 | version, 99 | versionOutput: `PowerShell ${version}`, 100 | isPrerelease: version.includes('rc') || version.includes('preview'), 101 | builds: files.map((file): Build => { 102 | let arch = file.arch; 103 | if(arch === 'arm32') arch = 'arm'; 104 | if(arch === 'x86') arch = 'ia32'; 105 | let platform = file.platform; 106 | if(platform=== 'osx') platform = 'darwin'; 107 | if(platform=== 'win') platform = 'win32'; 108 | return { 109 | platform: platform as NodeJS.Platform, 110 | arch: arch as Arch, 111 | extension: file.extension as Extension, 112 | sha256: file.sha256, 113 | url: file.url, 114 | bin: file.bin, 115 | }; 116 | }) 117 | }; 118 | }); 119 | 120 | function sortVersionsDescending(a: Version, b: Version) { 121 | const fix = (s: string) => s.replace(/^lts-/, ''); 122 | return compareSemver(fix(b.version), fix(a.version)); 123 | } 124 | versions.sort(sortVersionsDescending); 125 | 126 | // console.log(JSON.stringify(versions, null, 2)); 127 | console.log(outdent ` 128 | // Auto-generated by ./scripts/parse-versions.ts 129 | import { Version } from './version-utils'; 130 | 131 | // When a new version of powershell comes out, add the various downloads to this list. 132 | export const versions: ReadonlyArray> = ${ 133 | inspect(versions, {maxArrayLength: null, depth: null}).replace(/\n +/g, ($0) => `${$0}${$0.slice(1)}`) 134 | } 135 | `); 136 | // console.log(Object.keys(grouped)); 137 | } 138 | 139 | main(); 140 | -------------------------------------------------------------------------------- /scripts/build.ps1: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env pwsh 2 | param( 3 | <# compile TS and bundle via webpack #> 4 | [switch] $compile, 5 | [switch] $packageForTests, 6 | [switch] $test, 7 | [switch] $testWindows, 8 | [switch] $testWsl, 9 | [switch] $testPosix, 10 | [switch] $getPwshVersions, 11 | <# `npm version` and prepare CHANGELOG for new version #> 12 | [switch] $prepareVersion, 13 | [string] $npmVersionFlag, 14 | [switch] $version, 15 | # <# create all packages to be published to npm #> 16 | [switch] $packageForPublishing, 17 | # <# npm publish all tags #> 18 | [switch] $publishPackages, 19 | <# update CHANGELOG for next version #> 20 | [switch] $postPublish, 21 | [switch] $dryrun, 22 | [string]$winPwsh 23 | ) 24 | $BoundParamNames = $PSBoundParameters.Keys 25 | 26 | $ErrorActionPreference = 'Stop' 27 | Set-StrictMode -Version Latest 28 | 29 | . "$PSScriptRoot/helpers.ps1" 30 | 31 | if(($test -or $testWindows) -and (-not $winPwsh)) { 32 | $winPwshCmd = get-command pwsh.cmd -ea SilentlyContinue 33 | if(-not $winPwshCmd) { $winPwshCmd = get-command pwsh.exe } 34 | $winPwsh = $winPwshCmd.source 35 | } 36 | 37 | function validate { 38 | # if($pwshVersion -cne 'latest') { 39 | # if(-not ($versions -contains $pwshVersion)) { 40 | # throw "invalid powershell version $pwshVersion, valid values are:`n$( $versions -join "`n" )" 41 | # } 42 | # } 43 | } 44 | function main { 45 | 46 | validate 47 | 48 | if($getPwshVersions) { 49 | ( getPwshVersions ).version 50 | } 51 | 52 | if($compile) { 53 | write-host '----cleaning----' 54 | Write-Output out dist | % { (test-path $_) -and (Remove-Item -recurse $_) } | out-null 55 | write-host '----tsc----' 56 | run { tsc -p . } 57 | write-host '----webpack----' 58 | try { 59 | run { webpack } 60 | } catch { 61 | write-output $_ 62 | } 63 | write-host '----copying----' 64 | Copy-Item ./out/__root.js ./dist/ 65 | write-host '----done compiling----' 66 | } 67 | 68 | function forEachPwshVersion($pwshVersions, $action) { 69 | $npmBaseVersion = ( readfile package.json | convertfrom-json ).version 70 | foreach($pwshVersion in $pwshVersions) { 71 | $distTag = if(@('latest', 'prerelease') -contains $pwshVersion) { $pwshVersion } else { "pwsh$pwshVersion" } 72 | $npmVersion = if($pwshVersion -eq 'latest') { 73 | $npmBaseVersion 74 | } elseif ($pwshVersion -eq 'prerelease') { 75 | "$npmBaseVersion-prerelease" 76 | } else { 77 | "$npmBaseVersion-pwsh$pwshVersion" 78 | } 79 | $buildTags = @{ 80 | distTag = $distTag; 81 | pwshVersion = $pwshVersion; 82 | } 83 | $buildTags | convertto-json -depth 100 | out-file -encoding utf8 ./dist/buildTags.json 84 | & $action 85 | } 86 | } 87 | 88 | if($packageForTests) { 89 | forEachPwshVersion @('latest') { 90 | run { npm pack } 91 | } 92 | } 93 | 94 | if($test -or $testWindows) { 95 | write-host 'Testing in Windows' 96 | write-host ('pwsh path: ' + $winPwsh) 97 | & ./node_modules/.bin/mocha.cmd 98 | if($LASTEXITCODE -ne 0) {throw "Non-zero exit code: $LASTEXITCODE"} 99 | } 100 | if($test -or $testWsl) { 101 | write-host 'Testing in WSL (should be invoked from Windows)' 102 | # bash -l is required to set nvm PATHS 103 | bash -c "bash -l -c './node_modules/.bin/mocha'" 104 | if($LASTEXITCODE -ne 0) {throw "Non-zero exit code: $LASTEXITCODE"} 105 | } 106 | if($testPosix) { 107 | write-host 'Testing in Posix (should be invoked from within Linux, Mac, or WSL)' 108 | ./node_modules/.bin/mocha 109 | if($LASTEXITCODE -ne 0) {throw "Non-zero exit code: $LASTEXITCODE"} 110 | } 111 | 112 | if($prepareVersion) { 113 | if(-not ($BoundParamNames -contains 'npmVersionFlag')) { throw "must pass -npmVersionFlag" } 114 | write-host 'bumping npm version' 115 | run { npm version --no-git-tag-version --allow-same-version $npmVersionFlag } 116 | $npmVersion = (readfile package.json | convertfrom-json).version 117 | run { git add package.json } 118 | write-host 'preparing changelog...' 119 | writefile CHANGELOG.md ((readfile CHANGELOG.md) -replace 'vNEXT',"v$npmVersion") 120 | write-host 'Update and `git add` changelog. Make sure package.json version is accurate. Then run -version, -packageForPublish, -publishPackages, and finally -postPublish.' 121 | } 122 | 123 | if($version) { 124 | $npmBaseVersion = ( readfile package.json | convertfrom-json ).version 125 | write-host "Creating version commit and tag for $npmBaseVersion" 126 | if(-not $dryrun) { 127 | run { git add package.json } 128 | run { git commit -m "v$npmBaseVersion" --allow-empty } 129 | run { git tag "v$npmBaseVersion" } 130 | } 131 | } 132 | 133 | if($packageForPublishing) { 134 | $pwshVersions = & { 135 | 'latest' 136 | 'prerelease' 137 | ( getPwshVersions ).version 138 | } 139 | $pwshVersions 140 | $npmBaseVersion = ( readfile package.json | convertfrom-json ).version 141 | write-host "npmBaseVersion: $npmBaseVersion" 142 | if(test-path packages) { remove-item -Recurse packages } 143 | new-item -type directory packages 144 | forEachPwshVersion $pwshVersions { 145 | write-host '' 146 | Write-host 'PACKAGING:' 147 | write-host "npm version: $npmVersion" 148 | write-host "npm dist-tag: $distTag" 149 | write-host "pwsh version: $pwshVersion" 150 | write-host "buildTags.json: $(readfile ./dist/buildTags.json)" 151 | if(-not $dryrun) { 152 | run { npm version --no-git-tag-version $npmVersion --allow-same-version } 153 | run { npm pack } 154 | move-item *.tgz packages/package-$distTag.tgz 155 | } 156 | write-host '-----' 157 | } 158 | run { npm version --no-git-tag-version $npmBaseVersion --allow-same-version } 159 | } 160 | 161 | if($publishPackages) { 162 | get-childitem packages | % { 163 | $name = $_.name 164 | $distTag = (select-string -inputobject $name -pattern 'package-(.*).tgz').matches.groups[1].value 165 | run { npm publish --tag $distTag ./packages/$name } 166 | } 167 | } 168 | 169 | if($postPublish) { 170 | write-host 'adding vNEXT to changelog, committing to git' 171 | writefile CHANGELOG.md "# vNEXT`n`n* `n`n$(readfile CHANGELOG.md)" 172 | run { git add CHANGELOG.md } 173 | run { git commit -m "Bump changelog for next version" } 174 | 175 | run { git push } 176 | run { git push --tags } 177 | 178 | } 179 | } 180 | 181 | function getPwshVersions { 182 | $versions = run { ts-node --transpile-only -e @' 183 | console.log(JSON.stringify(require('./src/versions').versions)); 184 | '@ 185 | } | convertfrom-json 186 | $versions 187 | } 188 | 189 | $oldPwd = $pwd 190 | Set-Location "$PSScriptRoot/.." 191 | $oldPath = $env:PATH 192 | $env:PATH = "$pwd/node_modules/.bin$( [IO.Path]::PathSeparator )$env:PATH" 193 | try { 194 | main 195 | } finally { 196 | Set-location $oldPwd 197 | $env:PATH = $oldPath 198 | } 199 | -------------------------------------------------------------------------------- /test/main.spec.ts: -------------------------------------------------------------------------------- 1 | import {join, relative, resolve, basename, dirname} from 'path'; 2 | import {readdirSync, lstatSync, existsSync, statSync, realpathSync, symlinkSync, mkdirSync as mkdir, writeFileSync, readFileSync} from 'fs'; 3 | import {sync as which} from 'which'; 4 | import {sync as rimraf} from 'rimraf'; 5 | import {sync as execaSync} from 'execa'; 6 | import { promisify } from 'util'; 7 | import * as pathKey from 'path-key'; 8 | import * as assert from 'assert'; 9 | import outdent from 'outdent'; 10 | 11 | const IsWindows = process.platform === 'win32'; 12 | const IsPosix = !IsWindows; 13 | 14 | const pathSep = IsPosix ? ':' : ';'; 15 | const dirSep = IsPosix ? '/' : '\\'; 16 | const tgzPath = resolve(readdirSync(join(__dirname, '..')).find(v => basename(v).match(/pwsh-.*\.tgz/))!); 17 | // # $fromTgz = get-item $__dirname/../pwsh-*.tgz 18 | // # $tgz = "./this-is-the-tgz.tgz" 19 | // # remove-item $tgz -ea continue 20 | // # move-item $fromTgz $tgz 21 | const npmVersion = require('../package.json').version; 22 | const pwshVersion = require('../dist/buildTags.json').pwshVersion; 23 | 24 | function logBinaryLocations() { 25 | console.log('PATH:'); 26 | console.log(process.env[pathKey()]); 27 | console.log('BINARY PATHS:'); 28 | console.log(which('node')); 29 | console.log(which('npm')); 30 | console.log(which('pnpm')); 31 | console.log(which('pwsh')); 32 | } 33 | 34 | function Try(tryCb: () => T, catchCb: (error: any) => U): T | U { 35 | try { 36 | return tryCb(); 37 | } catch(e) { 38 | return catchCb(e); 39 | } 40 | } 41 | const npmBinary = which('npm'); 42 | const pnpmBinary = Try( 43 | () => which('pnpm'), 44 | (e) => { throw new Error('pnpm not found; you must have it installed to run tests. npm install -g pnpm'); } 45 | ); 46 | 47 | let winPwsh: string; 48 | if(IsWindows) { 49 | try { 50 | winPwsh = which('pwsh.cmd'); 51 | } catch { 52 | winPwsh = which('pwsh.exe'); 53 | } 54 | } 55 | 56 | const npmPrefixRealpath = `${__dirname}${ IsPosix ? '/real/prefix-posix' : '\\real\\prefix-windows' }`; 57 | const npmPrefixSymlink = `${__dirname}${ IsPosix ? '/prefix-link-posix' : '\\prefix-link-windows' }`; 58 | const npmGlobalInstallPath = `${npmPrefixSymlink}${ IsPosix ? '/bin' : '' }`; 59 | const npmLocalInstallPath = `${__dirname}${ dirSep }node_modules${ dirSep }.bin`; 60 | 61 | // <### HELPER FUNCTIONS ###> 62 | const delay = promisify(setTimeout); 63 | function assertSuccessExitCode(execaReturn: T): T { 64 | assert.equal(execaReturn.exitCode, 0); 65 | return execaReturn; 66 | } 67 | function logFence(message: string) { 68 | console.log('------------------------'); 69 | console.log(' ' + message); 70 | console.log('------------------------'); 71 | } 72 | function exec(args: string | string[], env?: Record) { 73 | if(typeof args === 'string') args = args.split(' '); 74 | logFence('STARTING: ' + args.join(' ')); 75 | const ret = execaSync(args[0], args.slice(1), { 76 | stdio: 'inherit', 77 | env: env ? {...process.env, ...env} : undefined 78 | }); 79 | logFence('FINISHED: ' + args.join(' ')); 80 | return assertSuccessExitCode(ret); 81 | } 82 | function execCapture(args: string | string[], env?: Record) { 83 | if(typeof args === 'string') args = args.split(' '); 84 | logFence('STARTING: ' + args.join(' ')); 85 | const ret = execaSync(args[0], args.slice(1), { 86 | stdio: 'pipe', 87 | input: '', 88 | env: env ? {...process.env, ...env} : undefined 89 | }); 90 | logFence('FINISHED: ' + args.join(' ')); 91 | return assertSuccessExitCode(ret); 92 | } 93 | const npmArgs = [npmBinary, '--userconfig', `${__dirname}${dirSep}.npmrc`]; 94 | const npmEnvVars = { 95 | NPM_CONFIG_PREFIX: undefined 96 | } 97 | const pnpmArgs = [pnpmBinary]; 98 | const pnpmEnvVars = { 99 | NPM_CONFIG_PREFIX: undefined, 100 | NPM_CONFIG_USERCONFIG: `${__dirname}${dirSep}.npmrc` 101 | }; 102 | async function retry(times: number, delayMs: number, block: () => void) { 103 | while(times) { 104 | try { 105 | block(); 106 | break; 107 | } catch(e) { 108 | times--; 109 | if(times <= 0) { 110 | throw e; 111 | } 112 | } 113 | await delay(delayMs); 114 | } 115 | } 116 | // # Create a symlink. 117 | function symlink(from: string, to: string) { 118 | const toAbs = resolve(dirname(from), to); 119 | if(existsSync(from) && lstatSync(from).isSymbolicLink && realpathSync(from) === toAbs) { 120 | console.log(`Symlink already exists: ${from} -> ${to}`); 121 | return 122 | } 123 | console.log(`Symlinking ${ from } --> ${ to }`); 124 | symlinkSync(to, from); 125 | } 126 | 127 | describe('pwsh', () => { 128 | let oldLocation: string; 129 | let oldPath: string; 130 | let preexistingPwsh: string; 131 | 132 | // cd to __dirname for duration of tests 133 | before(() => { 134 | oldLocation = process.cwd(); 135 | process.chdir(__dirname); 136 | }); 137 | after(() => { 138 | process.chdir(oldLocation); 139 | }); 140 | 141 | beforeEach(() => { 142 | // <### SETUP ENVIRONMENT ###> 143 | 144 | // # Add node_modules/.bin to PATH; remove any paths containing pwsh 145 | oldPath = process.env[pathKey()]; 146 | process.env[pathKey()] = [ 147 | // # Local bin 148 | npmLocalInstallPath, 149 | // # Global bin in npm prefix 150 | npmGlobalInstallPath, 151 | // # Path to node & npm, pnpm, which, sh, etc 152 | dirname(npmBinary), 153 | dirname(pnpmBinary), 154 | // # Path to sh 155 | IsPosix && dirname(which('sh')) 156 | ].filter(v => v).join(pathSep); 157 | 158 | // <### CLEAN ###> 159 | if(existsSync('./node_modules')) { 160 | rimraf('./node_modules'); 161 | } 162 | if(existsSync(npmPrefixRealpath)) { 163 | rimraf(npmPrefixRealpath); 164 | } 165 | 166 | mkdir(npmPrefixRealpath, {recursive: true}); 167 | symlink(npmPrefixSymlink, npmPrefixRealpath); 168 | 169 | // # Set npm prefix 170 | writeFileSync('.npmrc', outdent ` 171 | prefix = ${npmPrefix.replace('\\','\\\\')} 172 | pnpm-prefix = ${npmPrefix.replace('\\','\\\\')} 173 | `); 174 | 175 | preexistingPwsh = Try(() => which('pwsh'), () => null); 176 | }); 177 | 178 | afterEach(() => { 179 | process.env[pathKey()] = oldPath; 180 | }); 181 | 182 | let npmPrefix: string; 183 | describe('npm prefix is symlink', () => { 184 | npmPrefix = npmPrefixSymlink; 185 | tests(); 186 | }); 187 | 188 | describe('npm prefix is realpath', () => { 189 | npmPrefix = npmPrefixRealpath 190 | tests(); 191 | }); 192 | 193 | function tests() { 194 | it('npm prefix symlink exists', async () => { 195 | assert(lstatSync(npmPrefixSymlink).isSymbolicLink()); 196 | }); 197 | 198 | it('npm prefix set correctly for testing', async () => { 199 | assert.equal(execCapture([...npmArgs, 'config', 'get', 'prefix'], npmEnvVars).stdout, npmPrefix); 200 | }); 201 | 202 | describe('local installation', () => { 203 | describe('via npm', () => { 204 | beforeEach(async () => { 205 | exec([...npmArgs, 'install', tgzPath], npmEnvVars); 206 | }) 207 | localInstallationTests(); 208 | afterEach(async () => { 209 | exec([...npmArgs, 'uninstall', tgzPath], npmEnvVars); 210 | await deleteNodeModules(); 211 | }); 212 | }); 213 | describe('via pnpm', () => { 214 | beforeEach(async () => { 215 | exec([...pnpmArgs, 'install', tgzPath], pnpmEnvVars); 216 | }); 217 | localInstallationTests(); 218 | afterEach(async () => { 219 | const installedDependencyNames = Object.keys(JSON.parse(readFileSync('./package.json', 'utf8')).dependencies); 220 | assert.equal(installedDependencyNames.length, 1); 221 | exec([...pnpmArgs, 'uninstall', installedDependencyNames[0]], pnpmEnvVars); 222 | await deleteNodeModules(); 223 | }) 224 | }) 225 | function localInstallationTests() { 226 | it('pwsh is in path and is correct version', async () => { 227 | assert(which('pwsh').startsWith(npmLocalInstallPath)); 228 | 229 | if(pwshVersion !== 'latest') { 230 | assert.equal(execCapture('pwsh --version').stdout, `PowerShell v${ pwshVersion }`); 231 | } 232 | }); 233 | } 234 | async function deleteNodeModules() { 235 | console.log('deleting node_modules'); 236 | await retry(4, 1, () => { rimraf('node_modules'); }); 237 | console.log('deleted node_modules'); 238 | } 239 | }); 240 | describe('global installation', () => { 241 | beforeEach(async () => { 242 | exec([...npmArgs, 'install', '--global', tgzPath], npmEnvVars); 243 | }) 244 | it('pwsh is in path and is correct version', async () => { 245 | const pwshPath = which('pwsh'); 246 | assert(pwshPath.startsWith(npmGlobalInstallPath)); 247 | assert.notEqual(pwshPath, preexistingPwsh); 248 | if(pwshVersion !== 'latest') { 249 | assert.equal(execCapture('pwsh --version').stdout, `PowerShell v${pwshVersion}`); 250 | } 251 | }); 252 | afterEach(async () => { 253 | exec([...npmArgs, 'uninstall', '--global', tgzPath], npmEnvVars); 254 | }); 255 | }); 256 | } 257 | }); 258 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as os from 'os'; 3 | import * as Path from 'path'; 4 | import * as crypto from 'crypto'; 5 | import * as getStream from 'get-stream'; 6 | import * as request from 'request'; 7 | import * as requestPromise from 'request-promise'; 8 | import * as tar from 'tar'; 9 | import { Readable } from 'stream'; 10 | import { platform } from 'os'; 11 | import * as unzipper from 'unzipper'; 12 | import * as cmdShim from 'cmd-shim'; 13 | import { promisify } from 'util'; 14 | import { Extension } from './version-utils'; 15 | import * as stream from 'stream'; 16 | import {sync as spawnSync} from 'cross-spawn'; 17 | import { __root } from './__root'; 18 | 19 | export type TODO = any; 20 | 21 | /** 22 | * Fields baked into the package .tar.gz to configure behavior without any code changes. 23 | */ 24 | export interface BuildTags { 25 | /** npm dist-tag */ 26 | distTag: string; 27 | /** 28 | * Powershell version that this package should install, 29 | * 'latest' if it should install the newest compatible stable version, 30 | * or 'prerelease' if it should install the newest compatible version including prereleases */ 31 | pwshVersion: string; 32 | } 33 | export const buildTags: BuildTags = require('./buildTags.json'); 34 | 35 | /** return the sha256 of a file as a hex-formatted string */ 36 | export async function sha256OfFile(path: string): Promise { 37 | const input = fs.createReadStream(path); 38 | const cipher = crypto.createHash('sha256'); 39 | return (await getStream(input.pipe(cipher), {encoding: 'hex'})).toUpperCase(); 40 | } 41 | 42 | /** download a URL and save it to a file */ 43 | export async function downloadUrlToFile(url: string, path: string, requestOpts?: request.CoreOptions): Promise { 44 | const fileStream = fs.createWriteStream(path); 45 | await new Promise((res, rej) => { 46 | request(url, requestOpts).pipe(fileStream).on('close', () => { 47 | res(); 48 | }); 49 | }); 50 | } 51 | /** download a URL and return it as a string */ 52 | export async function downloadUrlAsString(url: string, requestOpts: requestPromise.RequestPromiseOptions): Promise { 53 | return await requestPromise(url, requestOpts); 54 | } 55 | /** download a URL and return it as parsed JSON */ 56 | export async function downloadUrlAsJson(url: string, requestOpts: requestPromise.RequestPromiseOptions): Promise { 57 | return JSON.parse(await downloadUrlAsString(url, requestOpts)); 58 | } 59 | 60 | export function readFileWithDefault(path: string, defaultContent: string): string { 61 | try { 62 | return fs.readFileSync(path, 'utf8'); 63 | } catch(e) { 64 | if(!fs.existsSync(path)) return defaultContent; 65 | throw e; 66 | } 67 | } 68 | 69 | /** Read a JSON file from disk, automatically parsing it, returning default value if the file doesn't exist. */ 70 | export function readJsonFileWithDefault(path: string, defaultContent: any): any { 71 | try { 72 | return JSON.parse(fs.readFileSync(path, 'utf8')); 73 | } catch(e) { 74 | if(!fs.existsSync(path)) return defaultContent; 75 | throw e; 76 | } 77 | } 78 | 79 | /** Write a JSON file to disk by stringify-ing the value. Writes UTF-8 encoding. */ 80 | export function writeJsonFile(path: string, value: any, indent: boolean | string = true) { 81 | if(indent === true) indent = ' '; 82 | fs.writeFileSync(path, JSON.stringify(value, null, indent as string)); 83 | } 84 | 85 | interface PatchJsonFileOpts { defaultValue: any; indent: string | boolean; } 86 | /** 87 | * Read JSON from a file, process it with a callback, then write the result back to the file 88 | * @param callback Returns new value to be stringified. If it returns undefined, original value is used, which is useful if you modified the original in-place. 89 | */ 90 | export function patchJsonFile(path: string, opts: PatchJsonFileOpts, callback: (v: any) => any); 91 | export function patchJsonFile(path: string, callback: (v: any) => any); 92 | export function patchJsonFile(path: string, _a: any, _b?: any) { 93 | let [opts = {}, callback] = [_a, _b]; 94 | if(!callback) [opts, callback] = [{}, _a]; 95 | const {defaultValue = null, indent = false} = opts; 96 | const value = readJsonFileWithDefault(path, defaultValue); 97 | let newValue = callback(value); 98 | if(newValue === undefined) newValue = value; 99 | writeJsonFile(path, newValue, indent); 100 | } 101 | 102 | export async function extractArchive(type: Extension, archivePath: string, destination: string) { 103 | switch(type) { 104 | case '.tar.gz': 105 | return extractTarFile(archivePath, destination); 106 | break; 107 | 108 | case '.zip': 109 | return extractZipFile(archivePath, destination); 110 | break; 111 | 112 | default: 113 | throw new Error('Unsupported archive type: ' + type); 114 | } 115 | } 116 | 117 | /** Extract a tar.gz file into a destination directory. */ 118 | export async function extractTarFile(tarPath: string, destination: string) { 119 | tar.x({ 120 | cwd: destination, 121 | file: tarPath, 122 | sync: true 123 | }); 124 | } 125 | 126 | /** Extract a zip file into a destination directory. */ 127 | export async function extractZipFile(zipPath: string, destination: string) { 128 | return await finished(fs.createReadStream(zipPath).pipe(unzipper.Extract({ path: destination }))); 129 | } 130 | 131 | function finished(stream: NodeJS.WritableStream, waitForEvent: 'end' | 'finish' = 'finish') { 132 | return new Promise((res, rej) => { 133 | stream.on(waitForEvent, () => { 134 | res(); 135 | }); 136 | stream.on('error', rej); 137 | }); 138 | } 139 | 140 | /** spawn a process on PATH, get the full stdout as a string. Throw if process returns non-zero status or anything else goes wrong. */ 141 | export function getStdout(commandAndArgs): string { 142 | const result = spawnSync(commandAndArgs[0], commandAndArgs.slice(1), { 143 | encoding: 'utf8' 144 | }); 145 | if(result.status !== 0) throw new Error(`process returned non-zero status: ${ result.status }`); 146 | return result.stdout.trim(); 147 | } 148 | 149 | /** Returns npm prefix, verbatim (no path normalization, straight from the `npm` command) */ 150 | export function getNpmPrefix(): string { 151 | // TODO memoize this 152 | return getStdout(['npm', 'config', 'get', 'prefix']); 153 | } 154 | 155 | /** get absolute path to the `bin` or `.bin` directory into which npm will install binaries (either symlinks or .cmd stubs) */ 156 | export function getNpmBinDirectory() { 157 | if(isGlobalInstall) { 158 | switch(process.platform) { 159 | case 'win32': 160 | return Path.normalize(getNpmPrefix()); 161 | break; 162 | 163 | case 'linux': 164 | case 'darwin': 165 | return Path.resolve(getNpmPrefix(), 'bin'); 166 | break; 167 | 168 | default: 169 | throw new Error(`Unsupported: global installation on ${ process.platform }`); 170 | } 171 | } 172 | // Local installation: find the local node_modules/.bin 173 | else { 174 | if(process.env.npm_lifecycle_event) { 175 | return Path.resolve(__root, '../.bin'); 176 | } else { 177 | // This only happens when we're testing 178 | return Path.resolve(__root, 'test-bin'); 179 | } 180 | } 181 | } 182 | 183 | /** 184 | * Return absolute, normalized path to the shim that NPM would generate for a package bin script 185 | */ 186 | export function getNpmBinShimPath(name: string) { 187 | return Path.normalize(Path.join(getNpmBinDirectory(), name + (process.platform === 'win32' ? '.cmd' : ''))); 188 | } 189 | 190 | /** 191 | * Return absolute, normalized path to npm's global node_modules directory, where modules are installed globally 192 | */ 193 | export function getNpmGlobalNodeModules() { 194 | switch(process.platform) { 195 | case 'win32': 196 | return Path.resolve(getNpmPrefix(), 'node_modules'); 197 | break; 198 | 199 | case 'linux': 200 | case 'darwin': 201 | return Path.resolve(getNpmPrefix(), 'lib', 'node_modules'); 202 | break; 203 | 204 | default: 205 | throw new Error(`Unsupported: global installation on ${ process.platform }`); 206 | } 207 | } 208 | 209 | /** True if this is an `npm install --global`, false if it's a local install */ 210 | export const isGlobalInstall = !!process.env.npm_config_global; 211 | 212 | /** Return true if paths point to the same file or directory, taking OS differences into account */ 213 | export function pathsEquivalent(path1: string, path2: string): boolean { 214 | path1 === fullyNormalizePath(path1); 215 | path2 === fullyNormalizePath(path2); 216 | return path1 === path2; 217 | } 218 | 219 | /** More aggressive path normalization. Convert to lowercase on Windows and strip trailing path separators */ 220 | export function fullyNormalizePath(path: string): string { 221 | path = Path.normalize(path); 222 | if(process.platform === 'win32') { 223 | path = path.toLowerCase(); 224 | } 225 | while(path[path.length - 1] === Path.sep) { 226 | path = path.slice(0, -1); 227 | } 228 | return path; 229 | } 230 | 231 | export function unlinkIfExistsSync(path: string) { 232 | try { 233 | fs.unlinkSync(path); 234 | } catch(e) { 235 | if(e.code !== 'ENOENT') throw e; 236 | } 237 | } 238 | 239 | /** 240 | * TODO the API and scope of this function is hacky and confusing (e.g. passing a logger instance) 241 | */ 242 | export async function createSymlinkTo(args: { 243 | linkPath: string; 244 | targetPath: string; 245 | intermediateLinkPath: string; 246 | log: typeof console['log'] 247 | }) { 248 | let {linkPath, targetPath, intermediateLinkPath, log} = args; 249 | // Resolve all symlinks to avoid problems computing relative paths 250 | try { 251 | // For linkPath, we don't actually *need* to resolve it, since we're not dealing with this file. (only target and intermediate paths) 252 | // Package managers that do funky things may not create the .bin directory we're expecting, so this may fail. 253 | // Gracefully fallback to the filename sans path. 254 | // This produces slightly less descriptive output, but at least it's not inaccurate. 255 | linkPath = fs.realpathSync(linkPath); 256 | } catch(e) { 257 | linkPath = Path.basename(linkPath); 258 | } 259 | targetPath = fs.realpathSync(targetPath); 260 | intermediateLinkPath = fs.realpathSync(intermediateLinkPath); 261 | // unlinkIfExistsSync(linkPath); 262 | unlinkIfExistsSync(intermediateLinkPath); 263 | // Windows platforms: use cmd-shim 264 | if(process.platform === 'win32') { 265 | log(`Creating .cmd shim from ${ linkPath } to ${ targetPath } (via ${ intermediateLinkPath })...`); 266 | await promisify(cmdShim)(targetPath, intermediateLinkPath.replace(/\.cmd$/, '')); 267 | } 268 | // Non-windows platforms: use a symlink to a symlink 269 | // Intermediate symlink is necessary because npm refuses to delete .bin/ unless it points to *within* the module's directory 270 | else { 271 | log(`Symlinking from ${ linkPath } to ${ targetPath } (via ${ intermediateLinkPath })...`); 272 | // fs.symlinkSync(intermediateLinkPath, linkPath); 273 | fs.symlinkSync(targetPath, intermediateLinkPath); 274 | } 275 | } 276 | --------------------------------------------------------------------------------