├── .npmrc ├── .gitattributes ├── .prettierrc.js ├── .gitignore ├── .prettierignore ├── src ├── bin │ ├── cross-env.ts │ └── cross-env-shell.ts ├── is-windows.ts ├── __tests__ │ ├── is-windows.test.ts │ ├── command.test.ts │ ├── command-default-values.test.ts │ ├── variable.test.ts │ └── index.test.ts ├── command.ts ├── variable.ts └── index.ts ├── eslint.config.js ├── CHANGELOG.md ├── tsconfig.json ├── vitest.config.ts ├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── validate.yml │ └── auto-format.yml ├── LICENSE ├── other ├── manual-releases.md └── CODE_OF_CONDUCT.md ├── package.json ├── e2e ├── test-cross-env-shell.js ├── test-cross-env.js └── test-default-values.js ├── README.md └── CONTRIBUTING.md /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require('kcd-scripts/prettier') 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .opt-in 5 | .opt-out 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | package-lock.json 5 | -------------------------------------------------------------------------------- /src/bin/cross-env.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { crossEnv } from '../index.js' 4 | 5 | crossEnv(process.argv.slice(2)) 6 | -------------------------------------------------------------------------------- /src/bin/cross-env-shell.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { crossEnv } from '../index.js' 4 | 5 | crossEnv(process.argv.slice(2), { shell: true }) 6 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import config from '@epic-web/config/eslint' 2 | 3 | export default [ 4 | ...config, 5 | { 6 | ignores: ['dist/**', 'coverage/**'], 7 | }, 8 | ] 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | The changelog is automatically updated using 4 | [semantic-release](https://github.com/semantic-release/semantic-release). You 5 | can see it on the [releases page](../../releases). 6 | -------------------------------------------------------------------------------- /src/is-windows.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Determines if the current platform is Windows 3 | * @returns true if running on Windows, false otherwise 4 | */ 5 | export function isWindows(): boolean { 6 | return ( 7 | process.platform === 'win32' || 8 | /^(msys|cygwin)$/.test(process.env.OSTYPE || '') 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@epic-web/config/typescript"], 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "module": "ESNext", 6 | "target": "ES2022", 7 | "moduleResolution": "bundler", 8 | "allowImportingTsExtensions": false, 9 | "noEmit": false 10 | }, 11 | "include": ["src/**/*", "**/*.config.ts"], 12 | "exclude": ["node_modules", "dist"] 13 | } 14 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | coverage: { 8 | provider: 'v8', 9 | reporter: ['text', 'json', 'html'], 10 | include: ['src/**/*'], 11 | exclude: [ 12 | 'node_modules/', 13 | 'dist/', 14 | '**/*.d.ts', 15 | '**/*.config.*', 16 | '**/coverage/**', 17 | '**/*.test.ts', 18 | '**/*.spec.ts', 19 | '**/src/bin/**', // covered by e2e 20 | '**/__tests__/**', 21 | ], 22 | }, 23 | }, 24 | }) 25 | -------------------------------------------------------------------------------- /src/__tests__/is-windows.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, afterEach, vi } from 'vitest' 2 | import { isWindows } from '../is-windows.js' 3 | 4 | const { 5 | env: { OSTYPE }, 6 | } = process 7 | 8 | afterEach(() => { 9 | process.env.OSTYPE = OSTYPE 10 | }) 11 | 12 | describe('isWindows', () => { 13 | test('returns true if the current OS is Windows', () => { 14 | vi.stubGlobal('process', { ...process, platform: 'win32' }) 15 | expect(isWindows()).toBe(true) 16 | }) 17 | 18 | test('returns false if the current OS is not Windows', () => { 19 | vi.stubGlobal('process', { ...process, platform: 'linux' }) 20 | expect(isWindows()).toBe(false) 21 | }) 22 | 23 | test('returns true if the OSTYPE is cygwin or msys', () => { 24 | vi.stubGlobal('process', { ...process, platform: 'linux' }) 25 | 26 | process.env.OSTYPE = 'cygwin' 27 | expect(isWindows()).toBe(true) 28 | 29 | process.env.OSTYPE = 'msys' 30 | expect(isWindows()).toBe(true) 31 | 32 | process.env.OSTYPE = '' 33 | expect(isWindows()).toBe(false) 34 | }) 35 | }) 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | - `cross-env` version: 15 | - `node` version: 16 | - `npm` (or `yarn`) version: 17 | 18 | Relevant code or config 19 | 20 | ```javascript 21 | 22 | ``` 23 | 24 | What you did: 25 | 26 | What happened: 27 | 28 | 29 | 30 | Reproduction repository: 31 | 32 | 36 | 37 | Problem description: 38 | 39 | Suggested solution: 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2017-2025 Kent C. Dodds 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | **What**: 20 | 21 | 22 | 23 | **Why**: 24 | 25 | 26 | 27 | **How**: 28 | 29 | 30 | 31 | **Checklist**: 32 | 33 | 34 | 35 | 36 | - [ ] Documentation 37 | - [ ] Tests 38 | - [ ] Ready to be merged 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /other/manual-releases.md: -------------------------------------------------------------------------------- 1 | # manual-releases 2 | 3 | This project has an automated release set up. So things are only released when 4 | there are useful changes in the code that justify a release. But sometimes 5 | things get messed up one way or another and we need to trigger the release 6 | ourselves. When this happens, simply bump the number below and commit that with 7 | the following commit message based on your needs: 8 | 9 | **Major** 10 | 11 | ``` 12 | fix(release): manually release a major version 13 | 14 | There was an issue with a major release, so this manual-releases.md 15 | change is to release a new major version. 16 | 17 | Reference: # 18 | 19 | BREAKING CHANGE: 20 | ``` 21 | 22 | **Minor** 23 | 24 | ``` 25 | feat(release): manually release a minor version 26 | 27 | There was an issue with a minor release, so this manual-releases.md 28 | change is to release a new minor version. 29 | 30 | Reference: # 31 | ``` 32 | 33 | **Patch** 34 | 35 | ``` 36 | fix(release): manually release a patch version 37 | 38 | There was an issue with a patch release, so this manual-releases.md 39 | change is to release a new patch version. 40 | 41 | Reference: # 42 | ``` 43 | 44 | The number of times we've had to do a manual release is: 2 45 | -------------------------------------------------------------------------------- /src/command.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { isWindows } from './is-windows.js' 3 | 4 | /** 5 | * Converts an environment variable usage to be appropriate for the current OS 6 | * @param command Command to convert 7 | * @param env Map of the current environment variable names and their values 8 | * @param normalize If the command should be normalized using `path` after converting 9 | * @returns Converted command 10 | */ 11 | export function commandConvert( 12 | command: string, 13 | env: NodeJS.ProcessEnv, 14 | normalize = false, 15 | ): string { 16 | if (!isWindows()) { 17 | return command 18 | } 19 | 20 | // Handle simple variables: $var or ${var} 21 | const simpleEnvRegex = /\$(\w+)|\${(\w+)}/g 22 | // Handle bash parameter expansion with default values: ${var:-default} 23 | const defaultValueRegex = /\$\{(\w+):-([^}]+)\}/g 24 | 25 | let convertedCmd = command 26 | 27 | // First, handle bash parameter expansion with default values 28 | convertedCmd = convertedCmd.replace( 29 | defaultValueRegex, 30 | (match, varName, defaultValue) => { 31 | // If the variable exists, use its value; otherwise use the default 32 | const value = env[varName] || defaultValue 33 | return value 34 | }, 35 | ) 36 | 37 | // Then handle simple variable references 38 | convertedCmd = convertedCmd.replace(simpleEnvRegex, (match, $1, $2) => { 39 | const varName = $1 || $2 40 | // In Windows, non-existent variables are not replaced by the shell, 41 | // so for example "echo %FOO%" will literally print the string "%FOO%", as 42 | // opposed to printing an empty string in UNIX. See kentcdodds/cross-env#145 43 | // If the env variable isn't defined at runtime, just strip it from the command entirely 44 | return env[varName] ? `%${varName}%` : '' 45 | }) 46 | 47 | // Normalization is required for commands with relative paths 48 | // For example, `./cmd.bat`. See kentcdodds/cross-env#127 49 | // However, it should not be done for command arguments. 50 | // See https://github.com/kentcdodds/cross-env/pull/130#issuecomment-319887970 51 | return normalize === true ? path.normalize(convertedCmd) : convertedCmd 52 | } 53 | -------------------------------------------------------------------------------- /src/variable.ts: -------------------------------------------------------------------------------- 1 | import { isWindows } from './is-windows.js' 2 | 3 | const pathLikeEnvVarWhitelist = new Set(['PATH', 'NODE_PATH']) 4 | 5 | /** 6 | * This will transform UNIX-style list values to Windows-style. 7 | * For example, the value of the $PATH variable "/usr/bin:/usr/local/bin:." 8 | * will become "/usr/bin;/usr/local/bin;." on Windows. 9 | * @param varValue Original value of the env variable 10 | * @param varName Original name of the env variable 11 | * @returns Converted value 12 | */ 13 | function replaceListDelimiters(varValue: string, varName = ''): string { 14 | const targetSeparator = isWindows() ? ';' : ':' 15 | if (!pathLikeEnvVarWhitelist.has(varName)) { 16 | return varValue 17 | } 18 | 19 | return varValue.replace(/(\\*):/g, (match, backslashes) => { 20 | if (backslashes.length % 2) { 21 | // Odd number of backslashes preceding it means it's escaped, 22 | // remove 1 backslash and return the rest as-is 23 | return match.substring(1) 24 | } 25 | return backslashes + targetSeparator 26 | }) 27 | } 28 | 29 | /** 30 | * This will attempt to resolve the value of any env variables that are inside 31 | * this string. For example, it will transform this: 32 | * cross-env FOO=$NODE_ENV BAR=\\$NODE_ENV echo $FOO $BAR 33 | * Into this: 34 | * FOO=development BAR=$NODE_ENV echo $FOO 35 | * (Or whatever value the variable NODE_ENV has) 36 | * Note that this function is only called with the right-side portion of the 37 | * env var assignment, so in that example, this function would transform 38 | * the string "$NODE_ENV" into "development" 39 | * @param varValue Original value of the env variable 40 | * @returns Converted value 41 | */ 42 | function resolveEnvVars(varValue: string): string { 43 | const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)})/g // $my_var or ${my_var} or \$my_var 44 | return varValue.replace( 45 | envUnixRegex, 46 | (_, escapeChars, varNameWithDollarSign, varName, altVarName) => { 47 | // do not replace things preceded by a odd number of \ 48 | if (escapeChars.length % 2 === 1) { 49 | return varNameWithDollarSign 50 | } 51 | return ( 52 | escapeChars.substring(0, escapeChars.length / 2) + 53 | (process.env[varName || altVarName] || '') 54 | ) 55 | }, 56 | ) 57 | } 58 | 59 | /** 60 | * Converts an environment variable value to be appropriate for the current OS. 61 | * @param originalValue Original value of the env variable 62 | * @param originalName Original name of the env variable 63 | * @returns Converted value 64 | */ 65 | export function varValueConvert( 66 | originalValue: string, 67 | originalName: string, 68 | ): string { 69 | return resolveEnvVars(replaceListDelimiters(originalValue, originalName)) 70 | } 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cross-env", 3 | "version": "0.0.0-semantically-released", 4 | "description": "Run scripts that set and use environment variables across platforms", 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "types": "./dist/index.d.ts", 8 | "bin": { 9 | "cross-env": "./dist/bin/cross-env.js", 10 | "cross-env-shell": "./dist/bin/cross-env-shell.js" 11 | }, 12 | "engines": { 13 | "node": ">=20" 14 | }, 15 | "scripts": { 16 | "build": "zshy", 17 | "dev": "zshy --watch", 18 | "lint": "eslint .", 19 | "lint:fix": "eslint . --fix", 20 | "format": "prettier --write .", 21 | "format:check": "prettier --check .", 22 | "typecheck": "tsc --noEmit", 23 | "test": "vitest", 24 | "test:ui": "vitest --ui", 25 | "test:run": "vitest run", 26 | "test:coverage": "vitest run --coverage", 27 | "test:e2e": "node e2e/test-cross-env.js && node e2e/test-cross-env-shell.js && node e2e/test-default-values.js", 28 | "validate": "npm run build && npm run typecheck && npm run lint && npm run format:check && npm run test:run" 29 | }, 30 | "files": [ 31 | "dist" 32 | ], 33 | "keywords": [ 34 | "cross-environment", 35 | "environment variable", 36 | "windows", 37 | "cross-platform" 38 | ], 39 | "author": "Kent C. Dodds (https://kentcdodds.com)", 40 | "license": "MIT", 41 | "dependencies": { 42 | "cross-spawn": "^7.0.6" 43 | }, 44 | "devDependencies": { 45 | "@epic-web/config": "^1.21.1", 46 | "@types/cross-spawn": "^6.0.6", 47 | "@types/node": "^24.1.0", 48 | "@vitest/coverage-v8": "^3.2.4", 49 | "@vitest/ui": "^3.2.4", 50 | "eslint": "^9.32.0", 51 | "prettier": "^3.6.2", 52 | "typescript": "^5.8.3", 53 | "vitest": "^3.2.4", 54 | "zshy": "^0.3.0" 55 | }, 56 | "repository": { 57 | "type": "git", 58 | "url": "https://github.com/kentcdodds/cross-env.git" 59 | }, 60 | "bugs": { 61 | "url": "https://github.com/kentcdodds/cross-env/issues" 62 | }, 63 | "homepage": "https://github.com/kentcdodds/cross-env#readme", 64 | "zshy": { 65 | "cjs": false, 66 | "exports": { 67 | ".": "./src/index.ts", 68 | "./bin/cross-env": "./src/bin/cross-env.ts", 69 | "./bin/cross-env-shell": "./src/bin/cross-env-shell.ts" 70 | } 71 | }, 72 | "prettier": "@epic-web/config/prettier", 73 | "module": "./dist/index.js", 74 | "exports": { 75 | ".": { 76 | "types": "./dist/index.d.ts", 77 | "import": "./dist/index.js" 78 | }, 79 | "./bin/cross-env": { 80 | "types": "./dist/bin/cross-env.d.ts", 81 | "import": "./dist/bin/cross-env.js" 82 | }, 83 | "./bin/cross-env-shell": { 84 | "types": "./dist/bin/cross-env-shell.d.ts", 85 | "import": "./dist/bin/cross-env-shell.js" 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/__tests__/command.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, vi, afterEach } from 'vitest' 2 | import { commandConvert } from '../command.js' 3 | import * as isWindowsModule from '../is-windows.js' 4 | 5 | vi.mock('../is-windows.js') 6 | 7 | const env = { 8 | test: 'a', 9 | test1: 'b', 10 | test2: 'c', 11 | test3: 'd', 12 | empty_var: '', 13 | } 14 | 15 | afterEach(() => { 16 | vi.clearAllMocks() 17 | }) 18 | 19 | describe('commandConvert', () => { 20 | test('converts unix-style env variable usage for windows', () => { 21 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 22 | expect(commandConvert('$test', env)).toBe('%test%') 23 | }) 24 | 25 | test('leaves command unchanged when not a variable', () => { 26 | expect(commandConvert('test', env)).toBe('test') 27 | }) 28 | 29 | test("doesn't convert windows-style env variable", () => { 30 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 31 | expect(commandConvert('%test%', env)).toBe('%test%') 32 | }) 33 | 34 | test('leaves variable unchanged when using correct operating system', () => { 35 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 36 | expect(commandConvert('$test', env)).toBe('$test') 37 | }) 38 | 39 | test('is stateless', () => { 40 | // this test prevents falling into regexp traps like this: 41 | // http://stackoverflow.com/a/1520853/971592 42 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 43 | expect(commandConvert('$test', env)).toBe(commandConvert('$test', env)) 44 | }) 45 | 46 | test('converts embedded unix-style env variables usage for windows', () => { 47 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 48 | expect(commandConvert('$test1/$test2/$test3', env)).toBe( 49 | '%test1%/%test2%/%test3%', 50 | ) 51 | }) 52 | 53 | test('leaves embedded variables unchanged when using correct operating system', () => { 54 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 55 | expect(commandConvert('$test1/$test2/$test3', env)).toBe( 56 | '$test1/$test2/$test3', 57 | ) 58 | }) 59 | 60 | test('converts braced unix-style env variable usage for windows', () => { 61 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 62 | expect(commandConvert('${test}', env)).toBe('%test%') 63 | }) 64 | 65 | test('removes non-existent variables from the converted command', () => { 66 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 67 | expect(commandConvert('$test1/$foo/$test2', env)).toBe('%test1%//%test2%') 68 | }) 69 | 70 | test('removes empty variables from the converted command', () => { 71 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 72 | expect(commandConvert('$foo/$test/$empty_var', env)).toBe('/%test%/') 73 | }) 74 | 75 | test('normalizes command on windows', () => { 76 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 77 | // index.js calls `commandConvert` with `normalize` param 78 | // as `true` for command only 79 | expect(commandConvert('./cmd.bat', env, true)).toBe('cmd.bat') 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /other/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of 9 | experience, nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | - Using welcoming and inclusive language 18 | - Being respectful of differing viewpoints and experiences 19 | - Gracefully accepting constructive criticism 20 | - Focusing on what is best for the community 21 | - Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | - The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | - Trolling, insulting/derogatory comments, and personal or political attacks 28 | - Public or private harassment 29 | - Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | - Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or reject 41 | comments, commits, code, wiki edits, issues, and other contributions that are 42 | not aligned to this Code of Conduct, or to ban temporarily or permanently any 43 | contributor for other behaviors that they deem inappropriate, threatening, 44 | offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at kent+coc@doddsfamily.us. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an 62 | incident. Further details of specific enforcement policies may be posted 63 | separately. 64 | 65 | Project maintainers who do not follow or enforce the Code of Conduct in good 66 | faith may face temporary or permanent repercussions as determined by other 67 | members of the project's leadership. 68 | 69 | ## Attribution 70 | 71 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 72 | version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 73 | 74 | [homepage]: http://contributor-covenant.org 75 | [version]: http://contributor-covenant.org/version/1/4/ 76 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { type SpawnOptions } from 'child_process' 2 | import { spawn } from 'cross-spawn' 3 | import { commandConvert } from './command.js' 4 | import { varValueConvert } from './variable.js' 5 | 6 | export type CrossEnvOptions = { 7 | shell?: boolean 8 | } 9 | 10 | export type ProcessResult = { 11 | exitCode: number | null 12 | signal?: string | null 13 | } 14 | 15 | const envSetterRegex = /(\w+)=('(.*)'|"(.*)"|(.*))/ 16 | 17 | export function crossEnv( 18 | args: string[], 19 | options: CrossEnvOptions = {}, 20 | ): ProcessResult | null { 21 | const [envSetters, command, commandArgs] = parseCommand(args) 22 | const env = getEnvVars(envSetters) 23 | 24 | if (command) { 25 | const spawnOptions: SpawnOptions = { 26 | stdio: 'inherit', 27 | shell: options.shell, 28 | env, 29 | } 30 | 31 | const proc = spawn( 32 | // run `path.normalize` for command(on windows) 33 | commandConvert(command, env, true), 34 | // by default normalize is `false`, so not run for cmd args 35 | commandArgs.map((arg) => commandConvert(arg, env)), 36 | spawnOptions, 37 | ) 38 | 39 | process.on('SIGTERM', () => proc.kill('SIGTERM')) 40 | process.on('SIGINT', () => proc.kill('SIGINT')) 41 | process.on('SIGBREAK', () => proc.kill('SIGBREAK')) 42 | process.on('SIGHUP', () => proc.kill('SIGHUP')) 43 | 44 | proc.on('exit', (code: number | null, signal?: string) => { 45 | let crossEnvExitCode = code 46 | // exit code could be null when OS kills the process(out of memory, etc) or due to node handling it 47 | // but if the signal is SIGINT the user exited the process so we want exit code 0 48 | if (crossEnvExitCode === null) { 49 | crossEnvExitCode = signal === 'SIGINT' ? 0 : 1 50 | } 51 | process.exit(crossEnvExitCode) 52 | }) 53 | 54 | return proc 55 | } 56 | 57 | return null 58 | } 59 | 60 | function parseCommand( 61 | args: string[], 62 | ): [Record, string | null, string[]] { 63 | const envSetters: Record = {} 64 | let command: string | null = null 65 | let commandArgs: string[] = [] 66 | 67 | for (let i = 0; i < args.length; i++) { 68 | const arg = args[i] 69 | if (!arg) continue 70 | const match = envSetterRegex.exec(arg) 71 | if (match && match[1]) { 72 | let value: string 73 | 74 | if (typeof match[3] !== 'undefined') { 75 | value = match[3] 76 | } else if (typeof match[4] === 'undefined') { 77 | value = match[5] || '' 78 | } else { 79 | value = match[4] 80 | } 81 | 82 | envSetters[match[1]] = value 83 | } else { 84 | // No more env setters, the rest of the line must be the command and args 85 | const cStart = args 86 | .slice(i) 87 | // Regex: 88 | // match "\'" or "'" 89 | // or match "\" if followed by [$"\] (lookahead) 90 | .map((a) => { 91 | const re = /\\\\|(\\)?'|([\\])(?=[$"\\])/g 92 | // Eliminate all matches except for "\'" => "'" 93 | return a.replace(re, (m) => { 94 | if (m === '\\\\') return '\\' 95 | if (m === "\\'") return "'" 96 | return '' 97 | }) 98 | }) 99 | const parsedCommand = cStart[0] 100 | if (!parsedCommand) { 101 | throw new Error('Command is required') 102 | } 103 | command = parsedCommand 104 | commandArgs = cStart.slice(1).filter(Boolean) 105 | break 106 | } 107 | } 108 | 109 | return [envSetters, command, commandArgs] 110 | } 111 | 112 | function getEnvVars(envSetters: Record): NodeJS.ProcessEnv { 113 | const envVars = { ...process.env } 114 | if (process.env.APPDATA) { 115 | envVars.APPDATA = process.env.APPDATA 116 | } 117 | Object.keys(envSetters).forEach((varName) => { 118 | const value = envSetters[varName] 119 | if (value !== undefined) { 120 | envVars[varName] = varValueConvert(value, varName) 121 | } 122 | }) 123 | return envVars 124 | } 125 | -------------------------------------------------------------------------------- /src/__tests__/command-default-values.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' 2 | import { commandConvert } from '../command.js' 3 | import * as isWindowsModule from '../is-windows.js' 4 | 5 | vi.mock('../is-windows.js') 6 | 7 | describe('commandConvert - Environment Variable Default Values', () => { 8 | beforeEach(() => { 9 | vi.clearAllMocks() 10 | }) 11 | 12 | afterEach(() => { 13 | vi.clearAllMocks() 14 | }) 15 | 16 | test('should handle ${VAR:-default} syntax on Windows when VAR is undefined', () => { 17 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 18 | const env = {} // PORT is not defined 19 | const command = 'wrangler dev --port ${PORT:-8787}' 20 | 21 | const result = commandConvert(command, env) 22 | 23 | // Should use the default value when PORT is not defined 24 | expect(result).toBe('wrangler dev --port 8787') 25 | }) 26 | 27 | test('should handle ${VAR:-default} syntax on Windows when VAR is defined', () => { 28 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 29 | const env = { PORT: '3000' } 30 | const command = 'wrangler dev --port ${PORT:-8787}' 31 | 32 | const result = commandConvert(command, env) 33 | 34 | // Should use the defined value when PORT is defined 35 | expect(result).toBe('wrangler dev --port 3000') 36 | }) 37 | 38 | test('should handle ${VAR:-default} syntax on Unix (no conversion needed)', () => { 39 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 40 | const env = {} // PORT is not defined 41 | const command = 'wrangler dev --port ${PORT:-8787}' 42 | 43 | const result = commandConvert(command, env) 44 | 45 | // On Unix, no conversion is needed 46 | expect(result).toBe('wrangler dev --port ${PORT:-8787}') 47 | }) 48 | 49 | test('should handle simple ${VAR} syntax on Windows', () => { 50 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 51 | const env = { PORT: '3000' } 52 | const command = 'wrangler dev --port ${PORT}' 53 | 54 | const result = commandConvert(command, env) 55 | 56 | // This works correctly 57 | expect(result).toBe('wrangler dev --port %PORT%') 58 | }) 59 | 60 | test('should handle simple ${VAR} syntax on Windows when VAR is undefined', () => { 61 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 62 | const env = {} // PORT is not defined 63 | const command = 'wrangler dev --port ${PORT}' 64 | 65 | const result = commandConvert(command, env) 66 | 67 | // This works correctly - strips undefined variables 68 | expect(result).toBe('wrangler dev --port ') 69 | }) 70 | 71 | test('should handle multiple default value syntaxes in one command', () => { 72 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 73 | const env = { HOST: 'localhost' } // PORT is not defined 74 | const command = 'wrangler dev --host ${HOST:-0.0.0.0} --port ${PORT:-8787}' 75 | 76 | const result = commandConvert(command, env) 77 | 78 | // Should use defined value for HOST and default for PORT 79 | expect(result).toBe('wrangler dev --host localhost --port 8787') 80 | }) 81 | 82 | test('should handle default values with special characters', () => { 83 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 84 | const env = {} // NODE_ENV is not defined 85 | const command = 'node app.js --env ${NODE_ENV:-development}' 86 | 87 | const result = commandConvert(command, env) 88 | 89 | // Should use the default value 90 | expect(result).toBe('node app.js --env development') 91 | }) 92 | 93 | test('should handle default values with spaces', () => { 94 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 95 | const env = {} // MESSAGE is not defined 96 | const command = 'echo ${MESSAGE:-hello world}' 97 | 98 | const result = commandConvert(command, env) 99 | 100 | // Should use the default value with spaces 101 | expect(result).toBe('echo hello world') 102 | }) 103 | }) 104 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Validate 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | build: 15 | name: 🔨 Build and Validate 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | node-version: [lts/-1, lts/*, latest] 20 | 21 | steps: 22 | - name: 📥 Checkout 23 | uses: actions/checkout@v5 24 | 25 | - name: 🟢 Setup Node.js ${{ matrix.node-version }} 26 | uses: actions/setup-node@v6 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | cache: 'npm' 30 | 31 | - name: 📦 Install dependencies 32 | run: npm ci 33 | 34 | - name: 🔨 Build 35 | run: npm run build 36 | 37 | - name: 💾 Cache build output 38 | uses: actions/cache/save@v4 39 | with: 40 | path: | 41 | dist/ 42 | key: build-${{ matrix.node-version }}-${{ github.sha }} 43 | 44 | - name: 🔍 Type check 45 | run: npm run typecheck 46 | 47 | - name: 🧹 Lint 48 | run: npm run lint 49 | 50 | - name: 🧪 Test 51 | run: npm run test:coverage 52 | 53 | - name: 📊 Upload coverage 54 | uses: codecov/codecov-action@v4 55 | with: 56 | token: ${{ secrets.CODECOV_TOKEN }} 57 | fail_ci_if_error: false 58 | 59 | e2e: 60 | name: 🌐 E2E Tests 61 | runs-on: ${{ matrix.os }} 62 | strategy: 63 | matrix: 64 | os: [ubuntu-latest, windows-latest] 65 | node-version: [lts/*] 66 | 67 | steps: 68 | - name: 📥 Checkout 69 | uses: actions/checkout@v5 70 | 71 | - name: 🟢 Setup Node.js ${{ matrix.node-version }} 72 | uses: actions/setup-node@v6 73 | with: 74 | node-version: ${{ matrix.node-version }} 75 | cache: 'npm' 76 | 77 | - name: 📦 Install dependencies 78 | run: npm ci 79 | 80 | - name: 🔨 Build 81 | run: npm run build 82 | 83 | # ensure we don't implicitly rely on dev dependencies 84 | - name: 📦 Prune dependencies 85 | run: npm prune --omit=dev 86 | 87 | - name: 🧪 Run cross-env e2e tests 88 | run: node e2e/test-cross-env.js 89 | 90 | - name: 🐚 Run cross-env-shell e2e tests 91 | run: node e2e/test-cross-env-shell.js 92 | 93 | - name: 🔧 Run default value syntax e2e tests 94 | run: node e2e/test-default-values.js 95 | 96 | release: 97 | name: 🚀 Release 98 | needs: [build, e2e] 99 | runs-on: ubuntu-latest 100 | permissions: 101 | contents: write # to be able to publish a GitHub release 102 | id-token: write # to enable use of OIDC for npm provenance 103 | issues: write # to be able to comment on released issues 104 | pull-requests: write # to be able to comment on released pull requests 105 | if: 106 | ${{ github.repository == 'kentcdodds/cross-env' && 107 | contains('refs/heads/main,refs/heads/beta,refs/heads/next,refs/heads/alpha', 108 | github.ref) && github.event_name == 'push' }} 109 | steps: 110 | - name: ⬇️ Checkout repo 111 | uses: actions/checkout@v5 112 | 113 | - name: 🔄 Restore build output 114 | uses: actions/cache/restore@v4 115 | with: 116 | path: | 117 | dist/ 118 | key: build-lts/*-${{ github.sha }} 119 | fail-on-cache-miss: false 120 | 121 | - name: 🚀 Release 122 | uses: cycjimmy/semantic-release-action@v5.0.2 123 | with: 124 | semantic_version: 25 125 | branches: | 126 | [ 127 | '+([0-9])?(.{+([0-9]),x}).x', 128 | 'main', 129 | 'next', 130 | 'next-major', 131 | {name: 'beta', prerelease: true}, 132 | {name: 'alpha', prerelease: true} 133 | ] 134 | env: 135 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 136 | -------------------------------------------------------------------------------- /e2e/test-cross-env-shell.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawn } from 'child_process' 4 | import { dirname, join } from 'path' 5 | import { fileURLToPath } from 'url' 6 | 7 | const __filename = fileURLToPath(import.meta.url) 8 | const __dirname = dirname(__filename) 9 | 10 | // Path to the built cross-env-shell binary 11 | const crossEnvShellPath = join( 12 | __dirname, 13 | '..', 14 | 'dist', 15 | 'bin', 16 | 'cross-env-shell.js', 17 | ) 18 | 19 | function runCommand(command, args = []) { 20 | return new Promise((resolve, reject) => { 21 | const child = spawn('node', [command, ...args], { 22 | stdio: ['pipe', 'pipe', 'pipe'], 23 | env: { ...process.env }, 24 | }) 25 | 26 | let stdout = '' 27 | let stderr = '' 28 | 29 | child.stdout.on('data', (data) => { 30 | stdout += data.toString() 31 | }) 32 | 33 | child.stderr.on('data', (data) => { 34 | stderr += data.toString() 35 | }) 36 | 37 | child.on('close', (code) => { 38 | if (code === 0) { 39 | resolve({ stdout, stderr, code }) 40 | } else { 41 | reject(new Error(`Command failed with code ${code}: ${stderr}`)) 42 | } 43 | }) 44 | 45 | child.on('error', (error) => { 46 | reject(error) 47 | }) 48 | }) 49 | } 50 | 51 | function runShellCommand(shellCommand, envVars = {}) { 52 | const envArgs = Object.entries(envVars).map( 53 | ([key, value]) => `${key}=${value}`, 54 | ) 55 | return runCommand(crossEnvShellPath, [...envArgs, shellCommand]) 56 | } 57 | 58 | async function runTests() { 59 | console.log('🧪 Running cross-env-shell e2e tests...') 60 | 61 | const tests = [ 62 | { 63 | name: 'Basic shell environment variable', 64 | test: async () => { 65 | const result = await runShellCommand('echo $TEST_VAR', { 66 | TEST_VAR: 'hello world', 67 | }) 68 | if (result.stdout.trim() !== 'hello world') { 69 | throw new Error( 70 | `Expected 'hello world', got '${result.stdout.trim()}'`, 71 | ) 72 | } 73 | }, 74 | }, 75 | { 76 | name: 'Multiple shell environment variables', 77 | test: async () => { 78 | const result = await runShellCommand('echo "$VAR1 $VAR2"', { 79 | VAR1: 'hello', 80 | VAR2: 'world', 81 | }) 82 | // The shell will handle this according to its own rules 83 | if ( 84 | !result.stdout.trim().includes('hello') || 85 | !result.stdout.trim().includes('world') 86 | ) { 87 | throw new Error( 88 | `Expected output to contain 'hello' and 'world', got '${result.stdout.trim()}'`, 89 | ) 90 | } 91 | }, 92 | }, 93 | { 94 | name: 'Shell command with environment variable in command', 95 | test: async () => { 96 | const result = await runShellCommand('echo $MESSAGE', { 97 | MESSAGE: 'hello from shell', 98 | }) 99 | if (!result.stdout.trim().includes('hello from shell')) { 100 | throw new Error( 101 | `Expected output to contain 'hello from shell', got '${result.stdout.trim()}'`, 102 | ) 103 | } 104 | }, 105 | }, 106 | { 107 | name: 'Shell command with environment variable substitution', 108 | test: async () => { 109 | const result = await runShellCommand('echo "Value is: $SUB_VAR"', { 110 | SUB_VAR: 'substituted', 111 | }) 112 | if (!result.stdout.trim().includes('substituted')) { 113 | throw new Error( 114 | `Expected output to contain 'substituted', got '${result.stdout.trim()}'`, 115 | ) 116 | } 117 | }, 118 | }, 119 | { 120 | name: 'Shell command with special characters in env var', 121 | test: async () => { 122 | const result = await runShellCommand('echo "$SPECIAL_VAR"', { 123 | SPECIAL_VAR: 'test@#$%^&*()_+-=[]{}|;:,.<>?', 124 | }) 125 | if (!result.stdout.trim().includes('test@#$%^&*()_+-=[]{}|;:,.<>?')) { 126 | throw new Error( 127 | `Expected output to contain special characters, got '${result.stdout.trim()}'`, 128 | ) 129 | } 130 | }, 131 | }, 132 | ] 133 | 134 | let passed = 0 135 | let failed = 0 136 | 137 | for (const test of tests) { 138 | try { 139 | console.log(` ✓ ${test.name}`) 140 | await test.test() 141 | passed++ 142 | } catch (error) { 143 | console.log(` ✗ ${test.name}: ${error.message}`) 144 | failed++ 145 | } 146 | } 147 | 148 | console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`) 149 | 150 | if (failed > 0) { 151 | process.exit(1) 152 | } 153 | 154 | console.log('✅ All cross-env-shell e2e tests passed!') 155 | } 156 | 157 | runTests().catch((error) => { 158 | console.error('❌ Test runner failed:', error) 159 | process.exit(1) 160 | }) 161 | -------------------------------------------------------------------------------- /src/__tests__/variable.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' 2 | import * as isWindowsModule from '../is-windows.js' 3 | import { varValueConvert } from '../variable.js' 4 | 5 | vi.mock('../is-windows.js') 6 | 7 | const JSON_VALUE = '{\\"foo\\":\\"bar\\"}' 8 | 9 | beforeEach(() => { 10 | process.env.VAR1 = 'value1' 11 | process.env.VAR2 = 'value2' 12 | process.env.JSON_VAR = JSON_VALUE 13 | }) 14 | 15 | afterEach(() => { 16 | const { env } = process 17 | delete env.VAR1 18 | delete env.VAR2 19 | delete env.JSON_VAR 20 | vi.clearAllMocks() 21 | }) 22 | 23 | describe('varValueConvert', () => { 24 | test("doesn't affect simple variable values", () => { 25 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 26 | expect(varValueConvert('foo', 'TEST')).toBe('foo') 27 | }) 28 | 29 | test("doesn't convert a ; into a : on UNIX", () => { 30 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 31 | expect(varValueConvert('foo;bar', 'PATH')).toBe('foo;bar') 32 | }) 33 | 34 | test("doesn't convert a ; into a : for non-PATH on UNIX", () => { 35 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 36 | expect(varValueConvert('foo;bar', 'FOO')).toBe('foo;bar') 37 | }) 38 | 39 | test("doesn't convert a ; into a : for non-PATH on Windows", () => { 40 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 41 | expect(varValueConvert('foo;bar', 'FOO')).toBe('foo;bar') 42 | }) 43 | 44 | test('converts a : into a ; on Windows if PATH', () => { 45 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 46 | expect(varValueConvert('foo:bar', 'PATH')).toBe('foo;bar') 47 | }) 48 | 49 | test("doesn't convert already valid separators", () => { 50 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 51 | expect(varValueConvert('foo:bar', 'TEST')).toBe('foo:bar') 52 | }) 53 | 54 | test("doesn't convert escaped separators on Windows if PATH", () => { 55 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 56 | expect(varValueConvert('foo\\:bar', 'PATH')).toBe('foo:bar') 57 | }) 58 | 59 | test("doesn't convert escaped separators on UNIX", () => { 60 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 61 | expect(varValueConvert('foo\\:bar', 'PATH')).toBe('foo:bar') 62 | }) 63 | 64 | test('converts a separator even if preceded by an escaped backslash', () => { 65 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 66 | expect(varValueConvert('foo\\\\:bar', 'PATH')).toBe('foo\\\\;bar') 67 | }) 68 | 69 | test('converts multiple separators if PATH', () => { 70 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 71 | expect(varValueConvert('foo:bar:baz', 'PATH')).toBe('foo;bar;baz') 72 | }) 73 | 74 | test('resolves an env variable value', () => { 75 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 76 | expect(varValueConvert('foo-$VAR1', 'TEST')).toBe('foo-value1') 77 | }) 78 | 79 | test('resolves an env variable value with curly syntax', () => { 80 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 81 | expect(varValueConvert('foo-${VAR1}', 'TEST')).toBe('foo-value1') 82 | }) 83 | 84 | test('resolves multiple env variable values', () => { 85 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 86 | expect(varValueConvert('foo-$VAR1-$VAR2', 'TEST')).toBe('foo-value1-value2') 87 | }) 88 | 89 | test('resolves an env variable value for non-existant variable', () => { 90 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 91 | expect(varValueConvert('foo-$VAR_POTATO', 'TEST')).toBe('foo-') 92 | }) 93 | 94 | test('resolves an env variable with a JSON string value on Windows', () => { 95 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 96 | expect(varValueConvert('$JSON_VAR', 'TEST')).toBe(JSON_VALUE) 97 | }) 98 | 99 | test('resolves an env variable with a JSON string value on UNIX', () => { 100 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 101 | expect(varValueConvert('$JSON_VAR', 'TEST')).toBe(JSON_VALUE) 102 | }) 103 | 104 | test('does not resolve an env variable prefixed with \\ on Windows', () => { 105 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 106 | expect(varValueConvert('\\$VAR1', 'TEST')).toBe('$VAR1') 107 | }) 108 | 109 | test('does not resolve an env variable prefixed with \\ on UNIX', () => { 110 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 111 | expect(varValueConvert('\\$VAR1', 'TEST')).toBe('$VAR1') 112 | }) 113 | 114 | test('resolves an env variable prefixed with \\\\ on Windows', () => { 115 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(true) 116 | expect(varValueConvert('\\\\$VAR1', 'TEST')).toBe('\\value1') 117 | }) 118 | 119 | test('resolves an env variable prefixed with \\\\ on UNIX', () => { 120 | vi.mocked(isWindowsModule.isWindows).mockReturnValue(false) 121 | expect(varValueConvert('\\\\$VAR1', 'TEST')).toBe('\\value1') 122 | }) 123 | }) 124 | -------------------------------------------------------------------------------- /e2e/test-cross-env.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawn } from 'child_process' 4 | import { dirname, join } from 'path' 5 | import { fileURLToPath } from 'url' 6 | 7 | const __filename = fileURLToPath(import.meta.url) 8 | const __dirname = dirname(__filename) 9 | 10 | // Path to the built cross-env binary 11 | const crossEnvPath = join(__dirname, '..', 'dist', 'bin', 'cross-env.js') 12 | 13 | function runCommand(command, args = []) { 14 | return new Promise((resolve, reject) => { 15 | const child = spawn('node', [command, ...args], { 16 | stdio: ['pipe', 'pipe', 'pipe'], 17 | env: { ...process.env }, 18 | }) 19 | 20 | let stdout = '' 21 | let stderr = '' 22 | 23 | child.stdout.on('data', (data) => { 24 | stdout += data.toString() 25 | }) 26 | 27 | child.stderr.on('data', (data) => { 28 | stderr += data.toString() 29 | }) 30 | 31 | child.on('close', (code) => { 32 | if (code === 0) { 33 | resolve({ stdout, stderr, code }) 34 | } else { 35 | reject(new Error(`Command failed with code ${code}: ${stderr}`)) 36 | } 37 | }) 38 | 39 | child.on('error', (error) => { 40 | reject(error) 41 | }) 42 | }) 43 | } 44 | 45 | function runNodeScript(script, envVars = {}) { 46 | const envArgs = Object.entries(envVars).map( 47 | ([key, value]) => `${key}=${value}`, 48 | ) 49 | return runCommand(crossEnvPath, [...envArgs, 'node', '-e', script]) 50 | } 51 | 52 | async function runTests() { 53 | console.log('🧪 Running cross-env e2e tests...') 54 | 55 | const tests = [ 56 | { 57 | name: 'Basic environment variable setting', 58 | test: async () => { 59 | const result = await runNodeScript( 60 | 'console.log(process.env.TEST_VAR)', 61 | { TEST_VAR: 'hello world' }, 62 | ) 63 | if (result.stdout.trim() !== 'hello world') { 64 | throw new Error( 65 | `Expected 'hello world', got '${result.stdout.trim()}'`, 66 | ) 67 | } 68 | }, 69 | }, 70 | { 71 | name: 'Multiple environment variables', 72 | test: async () => { 73 | const result = await runNodeScript( 74 | 'console.log(process.env.VAR1 + " " + process.env.VAR2)', 75 | { VAR1: 'hello', VAR2: 'world' }, 76 | ) 77 | if (result.stdout.trim() !== 'hello world') { 78 | throw new Error( 79 | `Expected 'hello world', got '${result.stdout.trim()}'`, 80 | ) 81 | } 82 | }, 83 | }, 84 | { 85 | name: 'Environment variable with spaces', 86 | test: async () => { 87 | const result = await runNodeScript( 88 | 'console.log(process.env.SPACE_VAR)', 89 | { SPACE_VAR: 'hello world with spaces' }, 90 | ) 91 | if (result.stdout.trim() !== 'hello world with spaces') { 92 | throw new Error( 93 | `Expected 'hello world with spaces', got '${result.stdout.trim()}'`, 94 | ) 95 | } 96 | }, 97 | }, 98 | { 99 | name: 'Environment variable with special characters', 100 | test: async () => { 101 | const result = await runNodeScript( 102 | 'console.log(process.env.SPECIAL_VAR)', 103 | { SPECIAL_VAR: 'test@#$%^&*()_+-=[]{}|;:,.<>?' }, 104 | ) 105 | if (result.stdout.trim() !== 'test@#$%^&*()_+-=[]{}|;:,.<>?') { 106 | throw new Error( 107 | `Expected 'test@#$%^&*()_+-=[]{}|;:,.<>?', got '${result.stdout.trim()}'`, 108 | ) 109 | } 110 | }, 111 | }, 112 | { 113 | name: 'Empty environment variable', 114 | test: async () => { 115 | const result = await runNodeScript( 116 | 'console.log("EMPTY:" + (process.env.EMPTY_VAR || "undefined"))', 117 | { EMPTY_VAR: '' }, 118 | ) 119 | if (result.stdout.trim() !== 'EMPTY:undefined') { 120 | throw new Error( 121 | `Expected 'EMPTY:undefined', got '${result.stdout.trim()}'`, 122 | ) 123 | } 124 | }, 125 | }, 126 | { 127 | name: 'Command with arguments', 128 | test: async () => { 129 | const result = await runCommand(crossEnvPath, [ 130 | 'TEST_ARG=value', 131 | 'node', 132 | '-e', 133 | 'console.log(process.argv[2] + " " + process.env.TEST_ARG)', 134 | 'arg1', 135 | ]) 136 | // The actual behavior is that process.argv[2] is undefined when using -e 137 | if (result.stdout.trim() !== 'undefined value') { 138 | throw new Error( 139 | `Expected 'undefined value', got '${result.stdout.trim()}'`, 140 | ) 141 | } 142 | }, 143 | }, 144 | ] 145 | 146 | let passed = 0 147 | let failed = 0 148 | 149 | for (const test of tests) { 150 | try { 151 | console.log(` ✓ ${test.name}`) 152 | await test.test() 153 | passed++ 154 | } catch (error) { 155 | console.log(` ✗ ${test.name}: ${error.message}`) 156 | failed++ 157 | } 158 | } 159 | 160 | console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`) 161 | 162 | if (failed > 0) { 163 | process.exit(1) 164 | } 165 | 166 | console.log('✅ All cross-env e2e tests passed!') 167 | } 168 | 169 | runTests().catch((error) => { 170 | console.error('❌ Test runner failed:', error) 171 | process.exit(1) 172 | }) 173 | -------------------------------------------------------------------------------- /.github/workflows/auto-format.yml: -------------------------------------------------------------------------------- 1 | name: 🔧 Auto Format 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main, develop] 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.ref }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | format: 16 | name: 🔧 Auto Format 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: write 20 | pull-requests: write 21 | steps: 22 | - name: 📥 Checkout code 23 | uses: actions/checkout@v5 24 | with: 25 | token: ${{ secrets.GITHUB_TOKEN }} 26 | ref: ${{ github.head_ref || github.ref_name }} 27 | 28 | - name: Setup Node.js 29 | uses: actions/setup-node@v6 30 | with: 31 | node-version: lts/* 32 | cache: 'npm' 33 | 34 | - name: Cache node_modules 35 | uses: actions/cache@v4 36 | with: 37 | path: node_modules 38 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 39 | restore-keys: | 40 | ${{ runner.os }}-node- 41 | 42 | - name: Install dependencies 43 | run: npm ci 44 | 45 | - name: Check current formatting 46 | id: format-check 47 | run: | 48 | if npm run format:check; then 49 | echo "format_needed=false" >> $GITHUB_OUTPUT 50 | echo "✅ Code is already properly formatted" 51 | else 52 | echo "format_needed=true" >> $GITHUB_OUTPUT 53 | echo "⚠️ Code formatting issues found" 54 | fi 55 | 56 | - name: Format code 57 | if: steps.format-check.outputs.format_needed == 'true' 58 | run: npm run format 59 | 60 | - name: Check for changes 61 | id: changes 62 | if: steps.format-check.outputs.format_needed == 'true' 63 | run: | 64 | if git diff --quiet; then 65 | echo "has_changes=false" >> $GITHUB_OUTPUT 66 | echo "ℹ️ No formatting changes to commit" 67 | else 68 | echo "has_changes=true" >> $GITHUB_OUTPUT 69 | echo "📝 Formatting changes detected" 70 | fi 71 | 72 | # CI cannot commit to workflow files, so we need to check for non-workflow changes 73 | - name: Check for non-workflow changes 74 | id: non-workflow-changes 75 | if: steps.changes.outputs.has_changes == 'true' 76 | run: | 77 | git add -A 78 | git reset .github/workflows/ 79 | if git diff --cached --quiet; then 80 | echo "has_non_workflow_changes=false" >> $GITHUB_OUTPUT 81 | echo "ℹ️ Only workflow files have formatting changes - skipping commit" 82 | else 83 | echo "has_non_workflow_changes=true" >> $GITHUB_OUTPUT 84 | echo "📝 Non-workflow formatting changes detected" 85 | fi 86 | 87 | - name: Configure Git 88 | if: 89 | steps.non-workflow-changes.outputs.has_non_workflow_changes == 'true' 90 | run: | 91 | git config --local user.email "action@github.com" 92 | git config --local user.name "GitHub Action" 93 | 94 | - name: Commit and push changes 95 | if: 96 | steps.non-workflow-changes.outputs.has_non_workflow_changes == 'true' 97 | env: 98 | TARGET_REF: ${{ github.head_ref || github.ref_name }} 99 | run: | 100 | git commit -m "chore: 🔧 Auto-format code with Prettier [skip ci] 101 | 102 | This commit was automatically generated by the auto-format workflow. 103 | Changes include: 104 | - Code formatting fixes 105 | - Consistent indentation 106 | - Proper line endings" 107 | git push origin HEAD:"$TARGET_REF" 108 | 109 | - name: Comment on PR 110 | if: 111 | steps.non-workflow-changes.outputs.has_non_workflow_changes == 'true' 112 | && github.event_name == 'pull_request' 113 | uses: actions/github-script@v7 114 | with: 115 | script: | 116 | github.rest.issues.createComment({ 117 | issue_number: context.issue.number, 118 | owner: context.repo.owner, 119 | repo: context.repo.repo, 120 | body: '🔧 **Auto-formatting applied!**\n\nI\'ve automatically formatted the code using Prettier and pushed the changes. The formatting is now consistent with the project standards.' 121 | }) 122 | 123 | - name: Success message 124 | if: 125 | steps.changes.outputs.has_changes == 'false' || 126 | steps.format-check.outputs.format_needed == 'false' || 127 | steps.non-workflow-changes.outputs.has_non_workflow_changes == 'false' 128 | run: | 129 | if [ "${{ steps.changes.outputs.has_changes }}" == "true" ] && [ "${{ steps.non-workflow-changes.outputs.has_non_workflow_changes }}" == "false" ]; then 130 | echo "✅ Only workflow files had formatting changes - no commit needed!" 131 | else 132 | echo "✅ No formatting changes needed - code is already properly formatted!" 133 | fi 134 | -------------------------------------------------------------------------------- /e2e/test-default-values.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { spawn } from 'child_process' 4 | import { dirname, join } from 'path' 5 | import { fileURLToPath } from 'url' 6 | 7 | const __filename = fileURLToPath(import.meta.url) 8 | const __dirname = dirname(__filename) 9 | 10 | // Path to the built cross-env binary 11 | const crossEnvPath = join(__dirname, '..', 'dist', 'bin', 'cross-env.js') 12 | 13 | function runCommand(command, args = []) { 14 | return new Promise((resolve, reject) => { 15 | const child = spawn('node', [command, ...args], { 16 | stdio: ['pipe', 'pipe', 'pipe'], 17 | env: { ...process.env }, 18 | }) 19 | 20 | let stdout = '' 21 | let stderr = '' 22 | 23 | child.stdout.on('data', (data) => { 24 | stdout += data.toString() 25 | }) 26 | 27 | child.stderr.on('data', (data) => { 28 | stderr += data.toString() 29 | }) 30 | 31 | child.on('close', (code) => { 32 | if (code === 0) { 33 | resolve({ stdout, stderr, code }) 34 | } else { 35 | reject(new Error(`Command failed with code ${code}: ${stderr}`)) 36 | } 37 | }) 38 | 39 | child.on('error', (error) => { 40 | reject(error) 41 | }) 42 | }) 43 | } 44 | 45 | async function runTests() { 46 | console.log('🧪 Running cross-env default value syntax e2e tests...') 47 | 48 | const tests = [ 49 | { 50 | name: 'Default value syntax when variable is undefined', 51 | test: async () => { 52 | // Test that the command conversion works by using echo to see the converted command 53 | const result = await runCommand(crossEnvPath, [ 54 | 'echo', 55 | 'wrangler dev --port ${PORT:-8787}', 56 | ]) 57 | 58 | // On Windows, this should convert to the default value 59 | // On Unix, it should remain unchanged 60 | const expected = 61 | process.platform === 'win32' 62 | ? 'wrangler dev --port 8787' 63 | : 'wrangler dev --port ${PORT:-8787}' 64 | if (result.stdout.trim() !== expected) { 65 | throw new Error( 66 | `Expected '${expected}', got '${result.stdout.trim()}'`, 67 | ) 68 | } 69 | }, 70 | }, 71 | { 72 | name: 'Default value syntax when variable is defined', 73 | test: async () => { 74 | const result = await runCommand(crossEnvPath, [ 75 | 'PORT=3000', 76 | 'echo', 77 | 'wrangler dev --port ${PORT:-8787}', 78 | ]) 79 | 80 | // On Windows, this should convert to the defined value 81 | // On Unix, it should remain unchanged 82 | const expected = 83 | process.platform === 'win32' 84 | ? 'wrangler dev --port 3000' 85 | : 'wrangler dev --port ${PORT:-8787}' 86 | if (result.stdout.trim() !== expected) { 87 | throw new Error( 88 | `Expected '${expected}', got '${result.stdout.trim()}'`, 89 | ) 90 | } 91 | }, 92 | }, 93 | { 94 | name: 'Multiple default value syntaxes', 95 | test: async () => { 96 | const result = await runCommand(crossEnvPath, [ 97 | 'HOST=localhost', 98 | 'echo', 99 | 'wrangler dev --host ${HOST:-0.0.0.0} --port ${PORT:-8787}', 100 | ]) 101 | 102 | // On Windows, should use defined value for HOST and default for PORT 103 | // On Unix, should remain unchanged 104 | const expected = 105 | process.platform === 'win32' 106 | ? 'wrangler dev --host localhost --port 8787' 107 | : 'wrangler dev --host ${HOST:-0.0.0.0} --port ${PORT:-8787}' 108 | if (result.stdout.trim() !== expected) { 109 | throw new Error( 110 | `Expected '${expected}', got '${result.stdout.trim()}'`, 111 | ) 112 | } 113 | }, 114 | }, 115 | { 116 | name: 'Default value with special characters', 117 | test: async () => { 118 | const result = await runCommand(crossEnvPath, [ 119 | 'echo', 120 | 'node app.js --env ${NODE_ENV:-development}', 121 | ]) 122 | 123 | // On Windows, should use the default value 124 | // On Unix, should remain unchanged 125 | const expected = 126 | process.platform === 'win32' 127 | ? 'node app.js --env development' 128 | : 'node app.js --env ${NODE_ENV:-development}' 129 | if (result.stdout.trim() !== expected) { 130 | throw new Error( 131 | `Expected '${expected}', got '${result.stdout.trim()}'`, 132 | ) 133 | } 134 | }, 135 | }, 136 | { 137 | name: 'Default value with spaces', 138 | test: async () => { 139 | const result = await runCommand(crossEnvPath, [ 140 | 'echo', 141 | 'echo ${MESSAGE:-hello world}', 142 | ]) 143 | 144 | // On Windows, should use the default value with spaces 145 | // On Unix, should remain unchanged 146 | const expected = 147 | process.platform === 'win32' 148 | ? 'echo hello world' 149 | : 'echo ${MESSAGE:-hello world}' 150 | if (result.stdout.trim() !== expected) { 151 | throw new Error( 152 | `Expected '${expected}', got '${result.stdout.trim()}'`, 153 | ) 154 | } 155 | }, 156 | }, 157 | ] 158 | 159 | let passed = 0 160 | let failed = 0 161 | 162 | for (const test of tests) { 163 | try { 164 | console.log(` ✓ ${test.name}`) 165 | await test.test() 166 | passed++ 167 | } catch (error) { 168 | console.log(` ✗ ${test.name}: ${error.message}`) 169 | failed++ 170 | } 171 | } 172 | 173 | console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`) 174 | 175 | if (failed > 0) { 176 | process.exit(1) 177 | } 178 | 179 | console.log('✅ All default value syntax e2e tests passed!') 180 | } 181 | 182 | runTests().catch((error) => { 183 | console.error('❌ Test runner failed:', error) 184 | process.exit(1) 185 | }) 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

cross-env 🔀

3 | 4 |

Run scripts that set and use environment variables across platforms

5 |
6 | 7 | **🎉 NOTICE: cross-env is "done" as in it does what it does and there's no need 8 | for new features. 9 | [Learn more](https://github.com/kentcdodds/cross-env/issues/257)** 10 | 11 | --- 12 | 13 | 14 | [![Build Status][build-badge]][build] 15 | [![Code Coverage][coverage-badge]][coverage] 16 | [![version][version-badge]][package] 17 | [![downloads][downloads-badge]][npmtrends] 18 | [![MIT License][license-badge]][license] 19 | 20 | 21 | ## The problem 22 | 23 | Most Windows command prompts will choke when you set environment variables with 24 | `NODE_ENV=production` like that. (The exception is [Bash on Windows][win-bash], 25 | which uses native Bash.) Similarly, there's a difference in how windows and 26 | POSIX commands utilize environment variables. With POSIX, you use: `$ENV_VAR` 27 | and on windows you use `%ENV_VAR%`. 28 | 29 | ## This solution 30 | 31 | `cross-env` makes it so you can have a single command without worrying about 32 | setting or using the environment variable properly for the platform. Just set it 33 | like you would if it's running on a POSIX system, and `cross-env` will take care 34 | of setting it properly. 35 | 36 | ## Installation 37 | 38 | This module is distributed via [npm][npm] which is bundled with [node][node] and 39 | should be installed as one of your project's `devDependencies`: 40 | 41 | ``` 42 | npm install --save-dev cross-env 43 | ``` 44 | 45 | > WARNING! Make sure that when you're installing packages that you spell things 46 | > correctly to avoid [mistakenly installing malware][malware] 47 | 48 | > NOTE : Version 8 of cross-env only supports Node.js 20 and higher, to use it 49 | > on Node.js 18 or lower install version 7 `npm install --save-dev cross-env@7` 50 | 51 | ## Usage 52 | 53 | I use this in my npm scripts: 54 | 55 | ```json 56 | { 57 | "scripts": { 58 | "build": "cross-env NODE_ENV=production node ./start.js --enable-turbo-mode" 59 | } 60 | } 61 | ``` 62 | 63 | Ultimately, the command that is executed (using [`cross-spawn`][cross-spawn]) 64 | is: 65 | 66 | ``` 67 | node ./start.js --enable-turbo-mode 68 | ``` 69 | 70 | The `NODE_ENV` environment variable will be set by `cross-env` 71 | 72 | You can set multiple environment variables at a time: 73 | 74 | ```json 75 | { 76 | "scripts": { 77 | "build": "cross-env FIRST_ENV=one SECOND_ENV=two node ./my-program" 78 | } 79 | } 80 | ``` 81 | 82 | You can also split a command into several ones, or separate the environment 83 | variables declaration from the actual command execution. You can do it this way: 84 | 85 | ```json 86 | { 87 | "scripts": { 88 | "parentScript": "cross-env GREET=\"Joe\" npm run childScript", 89 | "childScript": "cross-env-shell \"echo Hello $GREET\"" 90 | } 91 | } 92 | ``` 93 | 94 | Where `childScript` holds the actual command to execute and `parentScript` sets 95 | the environment variables to use. Then instead of run the childScript you run 96 | the parent. This is quite useful for launching the same command with different 97 | env variables or when the environment variables are too long to have everything 98 | in one line. It also means that you can use `$GREET` env var syntax even on 99 | Windows which would usually require it to be `%GREET%`. 100 | 101 | If you precede a dollar sign with an odd number of backslashes the expression 102 | statement will not be replaced. Note that this means backslashes after the JSON 103 | string escaping took place. `"FOO=\\$BAR"` will not be replaced. 104 | `"FOO=\\\\$BAR"` will be replaced though. 105 | 106 | Lastly, if you want to pass a JSON string (e.g., when using [ts-loader]), you 107 | can do as follows: 108 | 109 | ```json 110 | { 111 | "scripts": { 112 | "test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} node some_file.test.ts" 113 | } 114 | } 115 | ``` 116 | 117 | Pay special attention to the **triple backslash** `(\\\)` **before** the 118 | **double quotes** `(")` and the **absence** of **single quotes** `(')`. Both of 119 | these conditions have to be met in order to work both on Windows and UNIX. 120 | 121 | ## `cross-env` vs `cross-env-shell` 122 | 123 | The `cross-env` module exposes two bins: `cross-env` and `cross-env-shell`. The 124 | first one executes commands using [`cross-spawn`][cross-spawn], while the second 125 | one uses the `shell` option from Node's `spawn`. 126 | 127 | The main use case for `cross-env-shell` is when you need an environment variable 128 | to be set across an entire inline shell script, rather than just one command. 129 | 130 | For example, if you want to have the environment variable apply to several 131 | commands in series then you will need to wrap those in quotes and use 132 | `cross-env-shell` instead of `cross-env`. 133 | 134 | ```json 135 | { 136 | "scripts": { 137 | "greet": "cross-env-shell GREETING=Hi NAME=Joe \"echo $GREETING && echo $NAME\"" 138 | } 139 | } 140 | ``` 141 | 142 | The rule of thumb is: if you want to pass to `cross-env` a command that contains 143 | special shell characters _that you want interpreted_, then use 144 | `cross-env-shell`. Otherwise stick to `cross-env`. 145 | 146 | On Windows you need to use `cross-env-shell`, if you want to handle 147 | [signal events](https://nodejs.org/api/process.html#process_signal_events) 148 | inside of your program. A common case for that is when you want to capture a 149 | `SIGINT` event invoked by pressing `Ctrl + C` on the command-line interface. 150 | 151 | ## Windows Issues 152 | 153 | Please note that `npm` uses `cmd` by default and that doesn't support command 154 | substitution, so if you want to leverage that, then you need to update your 155 | `.npmrc` to set the `script-shell` to powershell. 156 | [Learn more here](https://github.com/kentcdodds/cross-env/issues/192#issuecomment-513341729). 157 | 158 | ## Inspiration 159 | 160 | I originally created this to solve a problem I was having with my npm scripts in 161 | [angular-formly][angular-formly]. This made contributing to the project much 162 | easier for Windows users. 163 | 164 | ## Other Solutions 165 | 166 | - [`env-cmd`](https://github.com/toddbluhm/env-cmd) - Reads environment 167 | variables from a file instead 168 | - [`@naholyr/cross-env`](https://www.npmjs.com/package/@naholyr/cross-env) - 169 | `cross-env` with support for setting default values 170 | 171 | ## LICENSE 172 | 173 | MIT 174 | 175 | 176 | [npm]: https://npmjs.com 177 | [node]: https://nodejs.org 178 | [build-badge]: https://img.shields.io/github/actions/workflow/status/kentcdodds/cross-env/validate.yml?branch=main&logo=github&style=flat-square 179 | [build]: https://github.com/kentcdodds/cross-env/actions?query=workflow%3Avalidate 180 | [coverage-badge]: https://img.shields.io/codecov/c/github/kentcdodds/cross-env.svg?style=flat-square 181 | [coverage]: https://codecov.io/github/kentcdodds/cross-env 182 | [version-badge]: https://img.shields.io/npm/v/cross-env.svg?style=flat-square 183 | [package]: https://www.npmjs.com/package/cross-env 184 | [downloads-badge]: https://img.shields.io/npm/dm/cross-env.svg?style=flat-square 185 | [npmtrends]: http://www.npmtrends.com/cross-env 186 | [license-badge]: https://img.shields.io/npm/l/cross-env.svg?style=flat-square 187 | [license]: https://github.com/kentcdodds/cross-env/blob/main/LICENSE 188 | 189 | [angular-formly]: https://github.com/formly-js/angular-formly 190 | [cross-spawn]: https://www.npmjs.com/package/cross-spawn 191 | [malware]: http://blog.npmjs.org/post/163723642530/crossenv-malware-on-the-npm-registry 192 | [ts-loader]: https://www.npmjs.com/package/ts-loader 193 | [win-bash]: https://msdn.microsoft.com/en-us/commandline/wsl/about 194 | 195 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as crossSpawnModule from 'cross-spawn' 2 | import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' 3 | import { crossEnv } from '../index.js' 4 | import * as isWindowsModule from '../is-windows.js' 5 | 6 | vi.mock('../is-windows.js') 7 | vi.mock('cross-spawn') 8 | 9 | const crossSpawnMock = vi.mocked(crossSpawnModule) 10 | const isWindowsMock = vi.mocked(isWindowsModule.isWindows) 11 | 12 | const getSpawned = (call = 0) => 13 | vi.mocked(crossSpawnMock.spawn).mock.results[call]?.value 14 | 15 | process.setMaxListeners(20) 16 | 17 | beforeEach(() => { 18 | vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) 19 | vi.mocked(crossSpawnMock.spawn).mockReturnValue({ 20 | on: vi.fn(), 21 | kill: vi.fn(), 22 | } as any) 23 | }) 24 | 25 | afterEach(() => { 26 | vi.clearAllMocks() 27 | vi.mocked(process.exit).mockRestore() 28 | }) 29 | 30 | describe('crossEnv', () => { 31 | test('sets environment variables and run the remaining command', () => { 32 | testEnvSetting({ FOO_ENV: 'production' }, 'FOO_ENV=production') 33 | }) 34 | 35 | test('APPDATA is undefined and not string', () => { 36 | testEnvSetting({ FOO_ENV: 'production', APPDATA: 2 }, 'FOO_ENV=production') 37 | }) 38 | 39 | test('handles multiple env variables', () => { 40 | testEnvSetting( 41 | { 42 | FOO_ENV: 'production', 43 | BAR_ENV: 'dev', 44 | APPDATA: '0', 45 | }, 46 | 'FOO_ENV=production', 47 | 'BAR_ENV=dev', 48 | 'APPDATA=0', 49 | ) 50 | }) 51 | 52 | test('handles special characters', () => { 53 | testEnvSetting({ FOO_ENV: './!?' }, 'FOO_ENV=./!?') 54 | }) 55 | 56 | test('handles single-quoted strings', () => { 57 | testEnvSetting({ FOO_ENV: 'bar env' }, "FOO_ENV='bar env'") 58 | }) 59 | 60 | test('handles double-quoted strings', () => { 61 | testEnvSetting({ FOO_ENV: 'bar env' }, 'FOO_ENV="bar env"') 62 | }) 63 | 64 | test('handles equality signs in quoted strings', () => { 65 | testEnvSetting({ FOO_ENV: 'foo=bar' }, 'FOO_ENV="foo=bar"') 66 | }) 67 | 68 | test('handles empty single-quoted strings', () => { 69 | testEnvSetting({ FOO_ENV: '' }, "FOO_ENV=''") 70 | }) 71 | 72 | test('handles empty double-quoted strings', () => { 73 | testEnvSetting({ FOO_ENV: '' }, 'FOO_ENV=""') 74 | }) 75 | 76 | test('handles no value after the equals sign', () => { 77 | testEnvSetting({ FOO_ENV: '' }, 'FOO_ENV=') 78 | }) 79 | 80 | test('handles quoted scripts', () => { 81 | crossEnv(['GREETING=Hi', 'NAME=Joe', 'echo $GREETING && echo $NAME'], { 82 | shell: true, 83 | }) 84 | expect(crossSpawnMock.spawn).toHaveBeenCalledWith( 85 | 'echo $GREETING && echo $NAME', 86 | [], 87 | { 88 | stdio: 'inherit', 89 | shell: true, 90 | env: { ...process.env, GREETING: 'Hi', NAME: 'Joe' }, 91 | }, 92 | ) 93 | }) 94 | 95 | test('handles escaped characters', () => { 96 | // this escapes \,",' and $ 97 | crossEnv( 98 | [ 99 | 'GREETING=Hi', 100 | 'NAME=Joe', 101 | 'echo \\"\\\'\\$GREETING\\\'\\" && echo $NAME', 102 | ], 103 | { 104 | shell: true, 105 | }, 106 | ) 107 | expect(crossSpawnMock.spawn).toHaveBeenCalledWith( 108 | 'echo "\'$GREETING\'" && echo $NAME', 109 | [], 110 | { 111 | stdio: 'inherit', 112 | shell: true, 113 | env: { ...process.env, GREETING: 'Hi', NAME: 'Joe' }, 114 | }, 115 | ) 116 | }) 117 | 118 | test('does nothing when given no command', () => { 119 | crossEnv([]) 120 | expect(crossSpawnMock.spawn).toHaveBeenCalledTimes(0) 121 | }) 122 | 123 | test('handles empty command after processing', () => { 124 | crossEnv(['FOO=bar', '']) 125 | expect(crossSpawnMock.spawn).toHaveBeenCalledTimes(0) 126 | }) 127 | 128 | test('normalizes commands on windows', () => { 129 | isWindowsMock.mockReturnValue(true) 130 | crossEnv(['./cmd.bat']) 131 | expect(crossSpawnMock.spawn).toHaveBeenCalledWith('cmd.bat', [], { 132 | stdio: 'inherit', 133 | env: { ...process.env }, 134 | }) 135 | }) 136 | 137 | test('does not normalize command arguments on windows', () => { 138 | isWindowsMock.mockReturnValue(true) 139 | crossEnv(['echo', 'http://example.com']) 140 | expect(crossSpawnMock.spawn).toHaveBeenCalledWith( 141 | 'echo', 142 | ['http://example.com'], 143 | { 144 | stdio: 'inherit', 145 | env: { ...process.env }, 146 | }, 147 | ) 148 | }) 149 | 150 | test('propagates kill signals', () => { 151 | testEnvSetting({ FOO_ENV: 'foo=bar' }, 'FOO_ENV="foo=bar"') 152 | 153 | process.emit('SIGTERM') 154 | process.emit('SIGINT') 155 | process.emit('SIGHUP') 156 | process.emit('SIGBREAK') 157 | const spawned = getSpawned() 158 | expect(spawned?.kill).toHaveBeenCalledWith('SIGTERM') 159 | expect(spawned?.kill).toHaveBeenCalledWith('SIGINT') 160 | expect(spawned?.kill).toHaveBeenCalledWith('SIGHUP') 161 | expect(spawned?.kill).toHaveBeenCalledWith('SIGBREAK') 162 | }) 163 | 164 | test('keeps backslashes', () => { 165 | isWindowsMock.mockReturnValue(true) 166 | crossEnv(['echo', '\\\\\\\\someshare\\\\somefolder']) 167 | expect(crossSpawnMock.spawn).toHaveBeenCalledWith( 168 | 'echo', 169 | ['\\\\someshare\\somefolder'], 170 | { 171 | stdio: 'inherit', 172 | env: { ...process.env }, 173 | }, 174 | ) 175 | }) 176 | 177 | test('propagates unhandled exit signal', () => { 178 | const { spawned } = testEnvSetting( 179 | { FOO_ENV: 'foo=bar' }, 180 | 'FOO_ENV="foo=bar"', 181 | ) 182 | const spawnExitCallback = spawned?.on.mock.calls[0]?.[1] as ( 183 | code: number | null, 184 | signal?: string, 185 | ) => void 186 | const spawnExitCode = null 187 | spawnExitCallback(spawnExitCode) 188 | expect(process.exit).toHaveBeenCalledWith(1) 189 | }) 190 | 191 | test('exits cleanly with SIGINT with a null exit code', () => { 192 | const { spawned } = testEnvSetting( 193 | { FOO_ENV: 'foo=bar' }, 194 | 'FOO_ENV="foo=bar"', 195 | ) 196 | const spawnExitCallback = spawned?.on.mock.calls[0]?.[1] as ( 197 | code: number | null, 198 | signal?: string, 199 | ) => void 200 | const spawnExitCode = null 201 | const spawnExitSignal = 'SIGINT' 202 | spawnExitCallback(spawnExitCode, spawnExitSignal) 203 | expect(process.exit).toHaveBeenCalledWith(0) 204 | }) 205 | 206 | test('propagates regular exit code', () => { 207 | const { spawned } = testEnvSetting( 208 | { FOO_ENV: 'foo=bar' }, 209 | 'FOO_ENV="foo=bar"', 210 | ) 211 | const spawnExitCallback = spawned?.on.mock.calls[0]?.[1] as ( 212 | code: number | null, 213 | signal?: string, 214 | ) => void 215 | const spawnExitCode = 0 216 | spawnExitCallback(spawnExitCode) 217 | expect(process.exit).toHaveBeenCalledWith(0) 218 | }) 219 | }) 220 | 221 | function testEnvSetting( 222 | expected: Record, 223 | ...envSettings: string[] 224 | ) { 225 | if (expected.APPDATA === 2) { 226 | // kill the APPDATA to test both is undefined 227 | const { env } = process 228 | delete env.APPDATA 229 | delete expected.APPDATA 230 | } else if (!process.env.APPDATA && expected.APPDATA === '0') { 231 | // set APPDATA and test it 232 | process.env.APPDATA = '0' 233 | } 234 | const ret = crossEnv([...envSettings, 'echo', 'hello world']) 235 | const env: Record = {} 236 | if (process.env.APPDATA) { 237 | env.APPDATA = process.env.APPDATA 238 | } 239 | Object.assign(env, expected) 240 | const spawned = getSpawned() 241 | expect(ret).toBe(spawned) 242 | expect(crossSpawnMock.spawn).toHaveBeenCalledTimes(1) 243 | expect(crossSpawnMock.spawn).toHaveBeenCalledWith('echo', ['hello world'], { 244 | stdio: 'inherit', 245 | shell: undefined, 246 | env: { ...process.env, ...env }, 247 | }) 248 | 249 | expect(spawned?.on).toHaveBeenCalledTimes(1) 250 | expect(spawned?.on).toHaveBeenCalledWith('exit', expect.any(Function)) 251 | return { spawned } 252 | } 253 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ 6 | series [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## Project setup 9 | 10 | 1. Fork and clone the repo 11 | 2. `npm install` to install dependencies 12 | 3. `npm run validate` to validate you've got it working 13 | 4. Create a branch for your PR 14 | 15 | > Tip: Keep your `main` branch pointing at the original repository and make pull 16 | > requests from branches on your fork. To do this, run: 17 | > 18 | > ``` 19 | > git remote add upstream https://github.com/kentcdodds/cross-env.git 20 | > git fetch upstream 21 | > git branch --set-upstream-to=upstream/main main 22 | > ``` 23 | > 24 | > This will add the original repository as a "remote" called "upstream," Then 25 | > fetch the git information from that remote, then set your local `main` branch 26 | > to use the upstream main branch whenever you run `git pull`. Then you can make 27 | > all of your pull request branches based on this `main` branch. Whenever you 28 | > want to update your version of `main`, do a regular `git pull`. 29 | 30 | ## Development 31 | 32 | This project uses modern tooling: 33 | 34 | - **TypeScript** for type safety 35 | - **Vitest** for testing 36 | - **ESLint** for linting 37 | - **Prettier** for formatting 38 | - **zshy** for building 39 | 40 | ### Available Scripts 41 | 42 | - `npm run build` - Build the project 43 | - `npm run dev` - Build in watch mode 44 | - `npm run test` - Run tests in watch mode 45 | - `npm run test:run` - Run tests once 46 | - `npm run test:coverage` - Run tests with coverage 47 | - `npm run lint` - Run ESLint 48 | - `npm run lint:fix` - Fix ESLint issues 49 | - `npm run format` - Format code with Prettier 50 | - `npm run format:check` - Check code formatting 51 | - `npm run typecheck` - Run TypeScript type checking 52 | - `npm run validate` - Run all checks (build, typecheck, lint, format, test) 53 | 54 | ## Help needed 55 | 56 | Please checkout the [the open issues][issues] 57 | 58 | Also, please watch the repo and respond to questions/bug reports/feature 59 | requests! Thanks! 60 | 61 | ## Contributors ✨ 62 | 63 | Thanks goes to these people ([emoji key][emojis]): 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |

Kent C. Dodds

💻 📖 🚇 ⚠️

Ya Zhuang

🔌 📖

James Harris

📖

compumike08

🐛 📖 ⚠️

Daniel Rodríguez Rivero

🐛 💻 📖

Jonas Keinholz

🐛 💻 ⚠️

Hugo Wood

🐛 💻 ⚠️

Thiebaud Thomas

🐛 💻 ⚠️

Daniel Rey López

💻 ⚠️

Amila Welihinda

🚇

Paul Betts

🐛 💻

Turner Hayes

🐛 💻 ⚠️

Suhas Karanth

💻 ⚠️

Sven

💻 📖 💡 ⚠️

D. Nicolás Lopez Zelaya

💻

Johan Hernandez

💻

Jordan Nielson

🐛 💻 ⚠️

Jason Cooke

📖

bibo5088

💻

Eric Berry

🔍

Michaël De Boey

💻

Lauri Eskola

📖

devuxer

📖

Daniel

📖
102 | 103 | 104 | 105 | 106 | 107 | 108 | This project follows the [all-contributors][all-contributors] specification. 109 | Contributions of any kind welcome! 110 | 111 | [egghead]: 112 | https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 113 | [all-contributors]: https://github.com/all-contributors/all-contributors 114 | [emojis]: https://allcontributors.org/docs/en/emoji-key 115 | [issues]: https://github.com/kentcdodds/cross-env/issues 116 | --------------------------------------------------------------------------------