feat: modernize cross-env with TypeScript, Vitest, and ESM-only build (#261)

NOTE: For the vast majority of users running supported versions of Node.js, this should not require any changes and you can update without issue.

BREAKING CHANGE: This is a major rewrite that changes the module format from CommonJS to ESM-only. The package now requires Node.js >=20 and only exports ESM modules (not relevant in most cases).

BREAKING CHANGE: The package.json exports have been updated to remove CommonJS support. Only ESM imports are now supported.

BREAKING CHANGE: All source files have been converted from JavaScript to TypeScript, requiring TypeScript-aware tooling for development.

- Replace Jest with Vitest for testing
- Convert all source files from .js to .ts with proper TypeScript types
- Use zshy for ESM-only builds (removes CJS support)
- Adopt @epic-web/config for TypeScript, ESLint, and Prettier
- Update to Node.js >=20 requirement
- Remove kcd-scripts dependency
- Add comprehensive e2e tests with GitHub Actions matrix testing
- Update GitHub workflow with caching and cross-platform testing
- Modernize documentation and remove outdated sections
- Update all dependencies to latest versions
- Add proper TypeScript declarations and exports

The tool maintains its original functionality while being completely modernized with the latest tooling and best practices

Co-authored-by: GitHub Action <[email protected]>
This commit is contained in:
Kent C. Dodds
2025-07-25 15:19:14 -06:00
committed by GitHub
co-authored by GitHub Action
parent 79a99c6d32
commit 0112288e01
41 changed files with 9444 additions and 1260 deletions
-255
View File
@@ -1,255 +0,0 @@
{
"projectName": "cross-env",
"projectOwner": "kentcdodds",
"repoType": "github",
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"contributors": [
{
"login": "kentcdodds",
"name": "Kent C. Dodds",
"avatar_url": "https://avatars.githubusercontent.com/u/1500684?v=3",
"profile": "https://kentcdodds.com",
"contributions": [
"code",
"doc",
"infra",
"test"
]
},
{
"login": "zhuangya",
"name": "Ya Zhuang ",
"avatar_url": "https://avatars1.githubusercontent.com/u/499038?v=3",
"profile": "https://zhuangya.me",
"contributions": [
"plugin",
"doc"
]
},
{
"login": "wopian",
"name": "James Harris",
"avatar_url": "https://avatars3.githubusercontent.com/u/3440094?v=3",
"profile": "https://wopian.me",
"contributions": [
"doc"
]
},
{
"login": "compumike08",
"name": "compumike08",
"avatar_url": "https://avatars1.githubusercontent.com/u/8941730?v=3",
"profile": "https://github.com/compumike08",
"contributions": [
"bug",
"doc",
"test"
]
},
{
"login": "danielo515",
"name": "Daniel Rodríguez Rivero",
"avatar_url": "https://avatars1.githubusercontent.com/u/2270425?v=3",
"profile": "https://github.com/danielo515",
"contributions": [
"bug",
"code",
"doc"
]
},
{
"login": "inyono",
"name": "Jonas Keinholz",
"avatar_url": "https://avatars2.githubusercontent.com/u/1508477?v=3",
"profile": "https://github.com/inyono",
"contributions": [
"bug",
"code",
"test"
]
},
{
"login": "hgwood",
"name": "Hugo Wood",
"avatar_url": "https://avatars3.githubusercontent.com/u/1656170?v=3",
"profile": "https://github.com/hgwood",
"contributions": [
"bug",
"code",
"test"
]
},
{
"login": "thomasthiebaud",
"name": "Thiebaud Thomas",
"avatar_url": "https://avatars0.githubusercontent.com/u/3715715?v=3",
"profile": "https://github.com/thomasthiebaud",
"contributions": [
"bug",
"code",
"test"
]
},
{
"login": "DanReyLop",
"name": "Daniel Rey López",
"avatar_url": "https://avatars1.githubusercontent.com/u/1715800?v=3",
"profile": "https://daniel.blog",
"contributions": [
"code",
"test"
]
},
{
"login": "amilajack",
"name": "Amila Welihinda",
"avatar_url": "https://avatars2.githubusercontent.com/u/6374832?v=3",
"profile": "http://amilajack.com",
"contributions": [
"infra"
]
},
{
"login": "paulcbetts",
"name": "Paul Betts",
"avatar_url": "https://avatars1.githubusercontent.com/u/1396?v=3",
"profile": "https://twitter.com/paulcbetts",
"contributions": [
"bug",
"code"
]
},
{
"login": "turnerhayes",
"name": "Turner Hayes",
"avatar_url": "https://avatars1.githubusercontent.com/u/6371670?v=3",
"profile": "https://github.com/turnerhayes",
"contributions": [
"bug",
"code",
"test"
]
},
{
"login": "sudo-suhas",
"name": "Suhas Karanth",
"avatar_url": "https://avatars2.githubusercontent.com/u/22251956?v=4",
"profile": "https://github.com/sudo-suhas",
"contributions": [
"code",
"test"
]
},
{
"login": "sventschui",
"name": "Sven",
"avatar_url": "https://avatars3.githubusercontent.com/u/512692?v=4",
"profile": "https://github.com/sventschui",
"contributions": [
"code",
"doc",
"example",
"test"
]
},
{
"login": "NicoZelaya",
"name": "D. Nicolás Lopez Zelaya",
"avatar_url": "https://avatars0.githubusercontent.com/u/5522668?v=4",
"profile": "https://github.com/NicoZelaya",
"contributions": [
"code"
]
},
{
"login": "bithavoc",
"name": "Johan Hernandez",
"avatar_url": "https://avatars3.githubusercontent.com/u/219289?v=4",
"profile": "http://bithavoc.io",
"contributions": [
"code"
]
},
{
"login": "jnielson94",
"name": "Jordan Nielson",
"avatar_url": "https://avatars3.githubusercontent.com/u/13559161?v=4",
"profile": "https://github.com/jnielson94",
"contributions": [
"bug",
"code",
"test"
]
},
{
"login": "Jason-Cooke",
"name": "Jason Cooke",
"avatar_url": "https://avatars0.githubusercontent.com/u/5185660?v=4",
"profile": "https://nz.linkedin.com/in/jsonc11",
"contributions": [
"doc"
]
},
{
"login": "bibo5088",
"name": "bibo5088",
"avatar_url": "https://avatars0.githubusercontent.com/u/17709887?v=4",
"profile": "https://github.com/bibo5088",
"contributions": [
"code"
]
},
{
"login": "coderberry",
"name": "Eric Berry",
"avatar_url": "https://avatars2.githubusercontent.com/u/12481?v=4",
"profile": "https://codefund.io",
"contributions": [
"fundingFinding"
]
},
{
"login": "MichaelDeBoey",
"name": "Michaël De Boey",
"avatar_url": "https://avatars3.githubusercontent.com/u/6643991?v=4",
"profile": "https://michaeldeboey.be",
"contributions": [
"code"
]
},
{
"login": "lauriii",
"name": "Lauri Eskola",
"avatar_url": "https://avatars0.githubusercontent.com/u/1845495?v=4",
"profile": "https://github.com/lauriii",
"contributions": [
"doc"
]
},
{
"login": "devuxer",
"name": "devuxer",
"avatar_url": "https://avatars0.githubusercontent.com/u/1298521?v=4",
"profile": "https://github.com/devuxer",
"contributions": [
"doc"
]
},
{
"login": "dsbert",
"name": "Daniel",
"avatar_url": "https://avatars2.githubusercontent.com/u/1320090?v=4",
"profile": "https://github.com/dsbert",
"contributions": [
"doc"
]
}
],
"repoHost": "https://github.com",
"contributorsPerLine": 7,
"skipCi": true
}
+1
View File
@@ -18,6 +18,7 @@ learn how: http://kcd.im/pull-request
Relevant code or config
```javascript
```
What you did:
+133
View File
@@ -0,0 +1,133 @@
name: Auto Format
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
format:
name: 🔧 Auto Format
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
ref: ${{ github.head_ref || github.ref_name }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: 'npm'
- name: Cache node_modules
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies
run: npm ci
- name: Check current formatting
id: format-check
run: |
if npm run format:check; then
echo "format_needed=false" >> $GITHUB_OUTPUT
echo "✅ Code is already properly formatted"
else
echo "format_needed=true" >> $GITHUB_OUTPUT
echo "⚠️ Code formatting issues found"
fi
- name: Format code
if: steps.format-check.outputs.format_needed == 'true'
run: npm run format
- name: Check for changes
id: changes
if: steps.format-check.outputs.format_needed == 'true'
run: |
if git diff --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "ℹ️ No formatting changes to commit"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "📝 Formatting changes detected"
fi
# CI cannot commit to workflow files, so we need to check for non-workflow changes
- name: Check for non-workflow changes
id: non-workflow-changes
if: steps.changes.outputs.has_changes == 'true'
run: |
git add -A
git reset .github/workflows/
if git diff --cached --quiet; then
echo "has_non_workflow_changes=false" >> $GITHUB_OUTPUT
echo "ℹ️ Only workflow files have formatting changes - skipping commit"
else
echo "has_non_workflow_changes=true" >> $GITHUB_OUTPUT
echo "📝 Non-workflow formatting changes detected"
fi
- name: Configure Git
if:
steps.non-workflow-changes.outputs.has_non_workflow_changes == 'true'
run: |
git config --local user.email "[email protected]"
git config --local user.name "GitHub Action"
- name: Commit and push changes
if:
steps.non-workflow-changes.outputs.has_non_workflow_changes == 'true'
env:
TARGET_REF: ${{ github.head_ref || github.ref_name }}
run: |
git commit -m "chore: 🔧 Auto-format code with Prettier [skip ci]
This commit was automatically generated by the auto-format workflow.
Changes include:
- Code formatting fixes
- Consistent indentation
- Proper line endings"
git push origin HEAD:"$TARGET_REF"
- name: Comment on PR
if:
steps.non-workflow-changes.outputs.has_non_workflow_changes == 'true'
&& github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
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.'
})
- name: Success message
if:
steps.changes.outputs.has_changes == 'false' ||
steps.format-check.outputs.format_needed == 'false' ||
steps.non-workflow-changes.outputs.has_non_workflow_changes == 'false'
run: |
if [ "${{ steps.changes.outputs.has_changes }}" == "true" ] && [ "${{ steps.non-workflow-changes.outputs.has_non_workflow_changes }}" == "false" ]; then
echo "✅ Only workflow files had formatting changes - no commit needed!"
else
echo "✅ No formatting changes needed - code is already properly formatted!"
fi
+71 -75
View File
@@ -1,88 +1,84 @@
name: validate
name: Validate
on:
push:
branches:
- '+([0-9])?(.{+([0-9]),x}).x'
- 'master'
- 'next'
- 'next-major'
- 'beta'
- 'alpha'
- '!all-contributors/**'
pull_request: {}
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
main:
# ignore all-contributors PRs
if: ${{ !contains(github.head_ref, 'all-contributors') }}
build:
name: 🔨 Build and Validate
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22, 24]
steps:
- name: 📥 Checkout
uses: actions/checkout@v4
- name: 🟢 Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: 📦 Install dependencies
run: npm ci
- name: 🔨 Build
run: npm run build
- name: 🔍 Type check
run: npm run typecheck
- name: 🧹 Lint
run: npm run lint
- name: 🧪 Test
run: npm run test:run
- name: 📊 Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
e2e:
name: 🌐 E2E Tests
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [10.14, 10, 12, 14, 15]
runs-on: ${{ matrix.os }}
node-version: [24]
steps:
- name: 🛑 Cancel Previous Runs
uses: styfle/[email protected]
- name: 📥 Checkout
uses: actions/checkout@v4
- name: 🟢 Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
access_token: ${{ secrets.GITHUB_TOKEN }}
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: ⬇️ Checkout repo
uses: actions/checkout@v2
- name: 📦 Install dependencies
run: npm ci
- name: ⎔ Setup node
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node }}
- name: 🔨 Build
run: npm run build
- name: 📥 Download deps
uses: bahmutov/npm-install@v1
with:
useLockFile: false
# ensure we don't implicitly rely on dev dependencies
- name: 📦 Prune dependencies
run: npm prune --omit=dev
- name: ▶️ Run validate script
run: npm run validate
- name: 🧪 Run cross-env e2e tests
run: node e2e/test-cross-env.js
- name: ⬆️ Upload coverage report
uses: codecov/codecov-action@v1
release:
needs: main
runs-on: ubuntu-latest
if:
${{ github.repository == 'kentcdodds/cross-env' &&
contains('refs/heads/master,refs/heads/beta,refs/heads/next,refs/heads/alpha',
github.ref) && github.event_name == 'push' }}
steps:
- name: 🛑 Cancel Previous Runs
uses: styfle/[email protected]
with:
access_token: ${{ secrets.GITHUB_TOKEN }}
- name: ⬇️ Checkout repo
uses: actions/checkout@v2
- name: ⎔ Setup node
uses: actions/setup-node@v1
with:
node-version: 14
- name: 📥 Download deps
uses: bahmutov/npm-install@v1
with:
useLockFile: false
- name: 🚀 Release
uses: cycjimmy/semantic-release-action@v2
with:
semantic_version: 17
branches: |
[
'+([0-9])?(.{+([0-9]),x}).x',
'master',
'next',
'next-major',
{name: 'beta', prerelease: true},
{name: 'alpha', prerelease: true}
]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: 🐚 Run cross-env-shell e2e tests
run: node e2e/test-cross-env-shell.js
-4
View File
@@ -3,7 +3,3 @@ coverage
dist
.opt-in
.opt-out
# Lock files
yarn.lock
package-lock.json
-1
View File
@@ -1 +0,0 @@
module.exports = require('kcd-scripts/husky')
-1
View File
@@ -1,2 +1 @@
registry=http://registry.npmjs.org/
package-lock=false
+1
View File
@@ -1,3 +1,4 @@
node_modules
coverage
dist
package-lock.json
+84 -9
View File
@@ -12,20 +12,44 @@ series [How to Contribute to an Open Source Project on GitHub][egghead]
3. `npm run validate` to validate you've got it working
4. Create a branch for your PR
> Tip: Keep your `master` branch pointing at the original repository and make
> pull requests from branches on your fork. To do this, run:
> Tip: Keep your `main` branch pointing at the original repository and make pull
> requests from branches on your fork. To do this, run:
>
> ```
> git remote add upstream https://github.com/kentcdodds/cross-env.git
> git fetch upstream
> git branch --set-upstream-to=upstream/master master
> git branch --set-upstream-to=upstream/main main
> ```
>
> This will add the original repository as a "remote" called "upstream," Then
> fetch the git information from that remote, then set your local `master`
> branch to use the upstream master branch whenever you run `git pull`. Then you
> can make all of your pull request branches based on this `master` branch.
> Whenever you want to update your version of `master`, do a regular `git pull`.
> fetch the git information from that remote, then set your local `main` branch
> to use the upstream main branch whenever you run `git pull`. Then you can make
> all of your pull request branches based on this `main` branch. Whenever you
> want to update your version of `main`, do a regular `git pull`.
## Development
This project uses modern tooling:
- **TypeScript** for type safety
- **Vitest** for testing
- **ESLint** for linting
- **Prettier** for formatting
- **zshy** for building
### Available Scripts
- `npm run build` - Build the project
- `npm run dev` - Build in watch mode
- `npm run test` - Run tests in watch mode
- `npm run test:run` - Run tests once
- `npm run test:coverage` - Run tests with coverage
- `npm run lint` - Run ESLint
- `npm run lint:fix` - Fix ESLint issues
- `npm run format` - Format code with Prettier
- `npm run format:check` - Check code formatting
- `npm run typecheck` - Run TypeScript type checking
- `npm run validate` - Run all checks (build, typecheck, lint, format, test)
## Help needed
@@ -34,7 +58,58 @@ Please checkout the [the open issues][issues]
Also, please watch the repo and respond to questions/bug reports/feature
requests! Thanks!
## Contributors ✨
Thanks goes to these people ([emoji key][emojis]):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://kentcdodds.com"><img src="https://avatars.githubusercontent.com/u/1500684?v=3" width="100px;" alt=""/><br /><sub><b>Kent C. Dodds</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=kentcdodds" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=kentcdodds" title="Documentation">📖</a> <a href="#infra-kentcdodds" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=kentcdodds" title="Tests">⚠️</a></td>
<td align="center"><a href="https://zhuangya.me"><img src="https://avatars1.githubusercontent.com/u/499038?v=3" width="100px;" alt=""/><br /><sub><b>Ya Zhuang </b></sub></a><br /><a href="#plugin-zhuangya" title="Plugin/utility libraries">🔌</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=zhuangya" title="Documentation">📖</a></td>
<td align="center"><a href="https://wopian.me"><img src="https://avatars3.githubusercontent.com/u/3440094?v=3" width="100px;" alt=""/><br /><sub><b>James Harris</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=wopian" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/compumike08"><img src="https://avatars1.githubusercontent.com/u/8941730?v=3" width="100px;" alt=""/><br /><sub><b>compumike08</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Acompumike08" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=compumike08" title="Documentation">📖</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=compumike08" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/danielo515"><img src="https://avatars1.githubusercontent.com/u/2270425?v=3" width="100px;" alt=""/><br /><sub><b>Daniel Rodríguez Rivero</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Adanielo515" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=danielo515" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=danielo515" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/inyono"><img src="https://avatars2.githubusercontent.com/u/1508477?v=3" width="100px;" alt=""/><br /><sub><b>Jonas Keinholz</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Ainyono" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=inyono" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=inyono" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/hgwood"><img src="https://avatars3.githubusercontent.com/u/1656170?v=3" width="100px;" alt=""/><br /><sub><b>Hugo Wood</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Ahgwood" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=hgwood" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=hgwood" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/thomasthiebaud"><img src="https://avatars0.githubusercontent.com/u/3715715?v=3" width="100px;" alt=""/><br /><sub><b>Thiebaud Thomas</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Athomasthiebaud" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=thomasthiebaud" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=thomasthiebaud" title="Tests">⚠️</a></td>
<td align="center"><a href="https://daniel.blog"><img src="https://avatars1.githubusercontent.com/u/1715800?v=3" width="100px;" alt=""/><br /><sub><b>Daniel Rey López</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=DanReyLop" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=DanReyLop" title="Tests">⚠️</a></td>
<td align="center"><a href="http://amilajack.com"><img src="https://avatars2.githubusercontent.com/u/6374832?v=3" width="100px;" alt=""/><br /><sub><b>Amila Welihinda</b></sub></a><br /><a href="#infra-amilajack" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://twitter.com/paulcbetts"><img src="https://avatars1.githubusercontent.com/u/1396?v=3" width="100px;" alt=""/><br /><sub><b>Paul Betts</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Apaulcbetts" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=paulcbetts" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/turnerhayes"><img src="https://avatars1.githubusercontent.com/u/6371670?v=3" width="100px;" alt=""/><br /><sub><b>Turner Hayes</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Aturnerhayes" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=turnerhayes" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=turnerhayes" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/sudo-suhas"><img src="https://avatars2.githubusercontent.com/u/22251956?v=4" width="100px;" alt=""/><br /><sub><b>Suhas Karanth</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=sudo-suhas" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=sudo-suhas" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/sventschui"><img src="https://avatars3.githubusercontent.com/u/512692?v=4" width="100px;" alt=""/><br /><sub><b>Sven</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=sventschui" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=sventschui" title="Documentation">📖</a> <a href="#example-sventschui" title="Examples">💡</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=sventschui" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/NicoZelaya"><img src="https://avatars0.githubusercontent.com/u/5522668?v=4" width="100px;" alt=""/><br /><sub><b>D. Nicolás Lopez Zelaya</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=NicoZelaya" title="Code">💻</a></td>
<td align="center"><a href="http://bithavoc.io"><img src="https://avatars3.githubusercontent.com/u/219289?v=4" width="100px;" alt=""/><br /><sub><b>Johan Hernandez</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=bithavoc" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jnielson94"><img src="https://avatars3.githubusercontent.com/u/13559161?v=4" width="100px;" alt=""/><br /><sub><b>Jordan Nielson</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Ajnielson94" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=jnielson94" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=jnielson94" title="Tests">⚠️</a></td>
<td align="center"><a href="https://nz.linkedin.com/in/jsonc11"><img src="https://avatars0.githubusercontent.com/u/5185660?v=4" width="100px;" alt=""/><br /><sub><b>Jason Cooke</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=Jason-Cooke" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/bibo5088"><img src="https://avatars0.githubusercontent.com/u/17709887?v=4" width="100px;" alt=""/><br /><sub><b>bibo5088</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=bibo5088" title="Code">💻</a></td>
<td align="center"><a href="https://codefund.io"><img src="https://avatars2.githubusercontent.com/u/12481?v=4" width="100px;" alt=""/><br /><sub><b>Eric Berry</b></sub></a><br /><a href="#fundingFinding-coderberry" title="Funding Finding">🔍</a></td>
<td align="center"><a href="https://michaeldeboey.be"><img src="https://avatars3.githubusercontent.com/u/6643991?v=4" width="100px;" alt=""/><br /><sub><b>Michaël De Boey</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=MichaelDeBoey" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/lauriii"><img src="https://avatars0.githubusercontent.com/u/1845495?v=4" width="100px;" alt=""/><br /><sub><b>Lauri Eskola</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=lauriii" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/devuxer"><img src="https://avatars0.githubusercontent.com/u/1298521?v=4" width="100px;" alt=""/><br /><sub><b>devuxer</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=devuxer" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/dsbert"><img src="https://avatars2.githubusercontent.com/u/1320090?v=4" width="100px;" alt=""/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=dsbert" title="Documentation">📖</a></td>
</tr>
</table>
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors][all-contributors] specification.
Contributions of any kind welcome!
[egghead]:
https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github
[all-contributors]: https://github.com/kentcdodds/all-contributors
https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github
[all-contributors]: https://github.com/all-contributors/all-contributors
[emojis]: https://allcontributors.org/docs/en/emoji-key
[issues]: https://github.com/kentcdodds/cross-env/issues
+1 -1
View File
@@ -1,5 +1,5 @@
The MIT License (MIT)
Copyright (c) 2017 Kent C. Dodds
Copyright (c) 2017-2025 Kent C. Dodds
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+43 -211
View File
@@ -1,82 +1,36 @@
<div align="center">
<h1>cross-env 🔀</h1>
# cross-env 🔀
<p>Run scripts that set and use environment variables across platforms</p>
</div>
Run scripts that set and use environment variables across platforms.
**🚨 NOTICE: cross-env still works well, but is in maintenance mode. No new
features will be added, only serious and common-case bugs will be fixed, and it
will only be kept up-to-date with Node.js over time.
[Learn more](https://github.com/kentcdodds/cross-env/issues/257)**
---
<!-- prettier-ignore-start -->
[![Build Status][build-badge]][build]
[![Code Coverage][coverage-badge]][coverage]
[![version][version-badge]][package]
[![downloads][downloads-badge]][npmtrends]
[![MIT License][license-badge]][license]
[![All Contributors][all-contributors-badge]](#contributors-)
[![Code of Conduct][coc-badge]][coc]
<!-- prettier-ignore-end -->
## The problem
## The Problem
Most Windows command prompts will choke when you set environment variables with
`NODE_ENV=production` like that. (The exception is [Bash on Windows][win-bash],
which uses native Bash.) Similarly, there's a difference in how windows and
POSIX commands utilize environment variables. With POSIX, you use: `$ENV_VAR`
and on windows you use `%ENV_VAR%`.
`NODE_ENV=production` like that. Similarly, there's a difference in how Windows
and POSIX commands utilize environment variables. With POSIX, you use:
`$ENV_VAR` and on Windows you use `%ENV_VAR%`.
## This solution
## The Solution
`cross-env` makes it so you can have a single command without worrying about
setting or using the environment variable properly for the platform. Just set it
like you would if it's running on a POSIX system, and `cross-env` will take care
of setting it properly.
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
- [Installation](#installation)
- [Usage](#usage)
- [`cross-env` vs `cross-env-shell`](#cross-env-vs-cross-env-shell)
- [Windows Issues](#windows-issues)
- [Inspiration](#inspiration)
- [Other Solutions](#other-solutions)
- [Issues](#issues)
- [🐛 Bugs](#-bugs)
- [💡 Feature Requests](#-feature-requests)
- [Contributors ✨](#contributors-)
- [LICENSE](#license)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
## Installation
This module is distributed via [npm][npm] which is bundled with [node][node] and
should be installed as one of your project's `devDependencies`:
```
```bash
npm install --save-dev cross-env
```
> WARNING! Make sure that when you're installing packages that you spell things
> correctly to avoid [mistakenly installing malware][malware]
> NOTE : Version 7 of cross-env only supports Node.js 10 and higher, to use it
> on Node.js 8 or lower install version 6 `npm install --save-dev cross-env@6`
## Usage
I use this in my npm scripts:
```json
{
"scripts": {
"build": "cross-env NODE_ENV=production webpack --config build/webpack.config.js"
}
"scripts": {
"build": "cross-env NODE_ENV=production node ./start.js"
}
}
```
@@ -84,211 +38,89 @@ Ultimately, the command that is executed (using [`cross-spawn`][cross-spawn])
is:
```
webpack --config build/webpack.config.js
node ./start.js
```
The `NODE_ENV` environment variable will be set by `cross-env`
The `NODE_ENV` environment variable will be set by `cross-env`.
### Multiple Environment Variables
You can set multiple environment variables at a time:
```json
{
"scripts": {
"build": "cross-env FIRST_ENV=one SECOND_ENV=two node ./my-program"
}
"scripts": {
"build": "cross-env FIRST_ENV=one SECOND_ENV=two node ./my-program"
}
}
```
### Complex Commands
You can also split a command into several ones, or separate the environment
variables declaration from the actual command execution. You can do it this way:
variables declaration from the actual command execution:
```json
{
"scripts": {
"parentScript": "cross-env GREET=\"Joe\" npm run childScript",
"childScript": "cross-env-shell \"echo Hello $GREET\""
}
"scripts": {
"parentScript": "cross-env GREET=\"Joe\" npm run childScript",
"childScript": "cross-env-shell \"echo Hello $GREET\""
}
}
```
Where `childScript` holds the actual command to execute and `parentScript` sets
the environment variables to use. Then instead of run the childScript you run
the parent. This is quite useful for launching the same command with different
env variables or when the environment variables are too long to have everything
in one line. It also means that you can use `$GREET` env var syntax even on
Windows which would usually require it to be `%GREET%`.
### JSON Strings
If you precede a dollar sign with an odd number of backslashes the expression
statement will not be replaced. Note that this means backslashes after the JSON
string escaping took place. `"FOO=\\$BAR"` will not be replaced.
`"FOO=\\\\$BAR"` will be replaced though.
Lastly, if you want to pass a JSON string (e.g., when using [ts-loader]), you
can do as follows:
For JSON strings (e.g., when using [ts-loader]):
```json
{
"scripts": {
"test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} node some_file.test.ts"
}
"scripts": {
"test": "cross-env TS_NODE_COMPILER_OPTIONS={\\\"module\\\":\\\"commonjs\\\"} node some_file.test.ts"
}
}
```
Pay special attention to the **triple backslash** `(\\\)` **before** the
**double quotes** `(")` and the **absence** of **single quotes** `(')`. Both of
these conditions have to be met in order to work both on Windows and UNIX.
**double quotes** `(")` and the **absence** of **single quotes** `(')`.
## `cross-env` vs `cross-env-shell`
The `cross-env` module exposes two bins: `cross-env` and `cross-env-shell`. The
first one executes commands using [`cross-spawn`][cross-spawn], while the second
one uses the `shell` option from Node's `spawn`.
The `cross-env` module exposes two bins: `cross-env` and `cross-env-shell`.
The main use case for `cross-env-shell` is when you need an environment variable
to be set across an entire inline shell script, rather than just one command.
- **`cross-env`**: Executes commands using [`cross-spawn`][cross-spawn]
- **`cross-env-shell`**: Uses the `shell` option from Node's `spawn`
For example, if you want to have the environment variable apply to several
commands in series then you will need to wrap those in quotes and use
`cross-env-shell` instead of `cross-env`.
Use `cross-env-shell` when you need an environment variable to be set across an
entire inline shell script, rather than just one command:
```json
{
"scripts": {
"greet": "cross-env-shell GREETING=Hi NAME=Joe \"echo $GREETING && echo $NAME\""
}
"scripts": {
"greet": "cross-env-shell GREETING=Hi NAME=Joe \"echo $GREETING && echo $NAME\""
}
}
```
The rule of thumb is: if you want to pass to `cross-env` a command that contains
**Rule of thumb**: If you want to pass to `cross-env` a command that contains
special shell characters _that you want interpreted_, then use
`cross-env-shell`. Otherwise stick to `cross-env`.
On Windows you need to use `cross-env-shell`, if you want to handle
[signal events](https://nodejs.org/api/process.html#process_signal_events)
inside of your program. A common case for that is when you want to capture a
`SIGINT` event invoked by pressing `Ctrl + C` on the command-line interface.
## Windows Issues
Please note that `npm` uses `cmd` by default and that doesn't support command
substitution, so if you want to leverage that, then you need to update your
`.npmrc` to set the `script-shell` to powershell.
[Learn more here](https://github.com/kentcdodds/cross-env/issues/192#issuecomment-513341729).
## Inspiration
## Requirements
I originally created this to solve a problem I was having with my npm scripts in
[angular-formly][angular-formly]. This made contributing to the project much
easier for Windows users.
- Node.js >= 20
## Other Solutions
- [`env-cmd`](https://github.com/toddbluhm/env-cmd) - Reads environment
variables from a file instead
- [`@naholyr/cross-env`](https://www.npmjs.com/package/@naholyr/cross-env) -
`cross-env` with support for setting default values
## Issues
_Looking to contribute? Look for the [Good First Issue][good-first-issue]
label._
### 🐛 Bugs
Please file an issue for bugs, missing documentation, or unexpected behavior.
[**See Bugs**][bugs]
### 💡 Feature Requests
This project is in maintenance mode and no new feature requests will be
considered.
[**Learn more**](https://github.com/kentcdodds/cross-env/issues/257)
## Contributors ✨
Thanks goes to these people ([emoji key][emojis]):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://kentcdodds.com"><img src="https://avatars.githubusercontent.com/u/1500684?v=3" width="100px;" alt=""/><br /><sub><b>Kent C. Dodds</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=kentcdodds" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=kentcdodds" title="Documentation">📖</a> <a href="#infra-kentcdodds" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=kentcdodds" title="Tests">⚠️</a></td>
<td align="center"><a href="https://zhuangya.me"><img src="https://avatars1.githubusercontent.com/u/499038?v=3" width="100px;" alt=""/><br /><sub><b>Ya Zhuang </b></sub></a><br /><a href="#plugin-zhuangya" title="Plugin/utility libraries">🔌</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=zhuangya" title="Documentation">📖</a></td>
<td align="center"><a href="https://wopian.me"><img src="https://avatars3.githubusercontent.com/u/3440094?v=3" width="100px;" alt=""/><br /><sub><b>James Harris</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=wopian" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/compumike08"><img src="https://avatars1.githubusercontent.com/u/8941730?v=3" width="100px;" alt=""/><br /><sub><b>compumike08</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Acompumike08" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=compumike08" title="Documentation">📖</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=compumike08" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/danielo515"><img src="https://avatars1.githubusercontent.com/u/2270425?v=3" width="100px;" alt=""/><br /><sub><b>Daniel Rodríguez Rivero</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Adanielo515" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=danielo515" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=danielo515" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/inyono"><img src="https://avatars2.githubusercontent.com/u/1508477?v=3" width="100px;" alt=""/><br /><sub><b>Jonas Keinholz</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Ainyono" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=inyono" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=inyono" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/hgwood"><img src="https://avatars3.githubusercontent.com/u/1656170?v=3" width="100px;" alt=""/><br /><sub><b>Hugo Wood</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Ahgwood" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=hgwood" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=hgwood" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/thomasthiebaud"><img src="https://avatars0.githubusercontent.com/u/3715715?v=3" width="100px;" alt=""/><br /><sub><b>Thiebaud Thomas</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Athomasthiebaud" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=thomasthiebaud" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=thomasthiebaud" title="Tests">⚠️</a></td>
<td align="center"><a href="https://daniel.blog"><img src="https://avatars1.githubusercontent.com/u/1715800?v=3" width="100px;" alt=""/><br /><sub><b>Daniel Rey López</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=DanReyLop" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=DanReyLop" title="Tests">⚠️</a></td>
<td align="center"><a href="http://amilajack.com"><img src="https://avatars2.githubusercontent.com/u/6374832?v=3" width="100px;" alt=""/><br /><sub><b>Amila Welihinda</b></sub></a><br /><a href="#infra-amilajack" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://twitter.com/paulcbetts"><img src="https://avatars1.githubusercontent.com/u/1396?v=3" width="100px;" alt=""/><br /><sub><b>Paul Betts</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Apaulcbetts" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=paulcbetts" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/turnerhayes"><img src="https://avatars1.githubusercontent.com/u/6371670?v=3" width="100px;" alt=""/><br /><sub><b>Turner Hayes</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Aturnerhayes" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=turnerhayes" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=turnerhayes" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/sudo-suhas"><img src="https://avatars2.githubusercontent.com/u/22251956?v=4" width="100px;" alt=""/><br /><sub><b>Suhas Karanth</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=sudo-suhas" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=sudo-suhas" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/sventschui"><img src="https://avatars3.githubusercontent.com/u/512692?v=4" width="100px;" alt=""/><br /><sub><b>Sven</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=sventschui" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=sventschui" title="Documentation">📖</a> <a href="#example-sventschui" title="Examples">💡</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=sventschui" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/NicoZelaya"><img src="https://avatars0.githubusercontent.com/u/5522668?v=4" width="100px;" alt=""/><br /><sub><b>D. Nicolás Lopez Zelaya</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=NicoZelaya" title="Code">💻</a></td>
<td align="center"><a href="http://bithavoc.io"><img src="https://avatars3.githubusercontent.com/u/219289?v=4" width="100px;" alt=""/><br /><sub><b>Johan Hernandez</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=bithavoc" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jnielson94"><img src="https://avatars3.githubusercontent.com/u/13559161?v=4" width="100px;" alt=""/><br /><sub><b>Jordan Nielson</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/issues?q=author%3Ajnielson94" title="Bug reports">🐛</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=jnielson94" title="Code">💻</a> <a href="https://github.com/kentcdodds/cross-env/commits?author=jnielson94" title="Tests">⚠️</a></td>
<td align="center"><a href="https://nz.linkedin.com/in/jsonc11"><img src="https://avatars0.githubusercontent.com/u/5185660?v=4" width="100px;" alt=""/><br /><sub><b>Jason Cooke</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=Jason-Cooke" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/bibo5088"><img src="https://avatars0.githubusercontent.com/u/17709887?v=4" width="100px;" alt=""/><br /><sub><b>bibo5088</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=bibo5088" title="Code">💻</a></td>
<td align="center"><a href="https://codefund.io"><img src="https://avatars2.githubusercontent.com/u/12481?v=4" width="100px;" alt=""/><br /><sub><b>Eric Berry</b></sub></a><br /><a href="#fundingFinding-coderberry" title="Funding Finding">🔍</a></td>
<td align="center"><a href="https://michaeldeboey.be"><img src="https://avatars3.githubusercontent.com/u/6643991?v=4" width="100px;" alt=""/><br /><sub><b>Michaël De Boey</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=MichaelDeBoey" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/lauriii"><img src="https://avatars0.githubusercontent.com/u/1845495?v=4" width="100px;" alt=""/><br /><sub><b>Lauri Eskola</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=lauriii" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/devuxer"><img src="https://avatars0.githubusercontent.com/u/1298521?v=4" width="100px;" alt=""/><br /><sub><b>devuxer</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=devuxer" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/dsbert"><img src="https://avatars2.githubusercontent.com/u/1320090?v=4" width="100px;" alt=""/><br /><sub><b>Daniel</b></sub></a><br /><a href="https://github.com/kentcdodds/cross-env/commits?author=dsbert" title="Documentation">📖</a></td>
</tr>
</table>
<!-- markdownlint-enable -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors][all-contributors] specification.
Contributions of any kind welcome!
> Note: this was added late into the project. If you've contributed to this
> project in any way, please make a pull request to add yourself to the list by
> following the instructions in the `CONTRIBUTING.md`
## LICENSE
## License
MIT
<!-- prettier-ignore-start -->
[npm]: https://npmjs.com
[node]: https://nodejs.org
<!-- you caught me! The builds have been deleted, but I promise they were passing before now! -->
[build-badge]: https://camo.githubusercontent.com/748e4a1386b345c47427adeb7025b74850b550e5b1deffecd6779c374b887184/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f6b656e7463646f6464732f6d617463682d736f727465722f76616c69646174652e796d6c3f6c6f676f3d676974687562267374796c653d666c61742d737175617265266272616e63683d6d61696e
[build]: https://github.com/kentcdodds/cross-env/actions?query=workflow%3Avalidate
[coverage-badge]: https://img.shields.io/codecov/c/github/kentcdodds/cross-env.svg?style=flat-square
[coverage]: https://codecov.io/github/kentcdodds/cross-env
[version-badge]: https://img.shields.io/npm/v/cross-env.svg?style=flat-square
[package]: https://www.npmjs.com/package/cross-env
[downloads-badge]: https://img.shields.io/npm/dm/cross-env.svg?style=flat-square
[npmtrends]: http://www.npmtrends.com/cross-env
[license-badge]: https://img.shields.io/npm/l/cross-env.svg?style=flat-square
[license]: https://github.com/kentcdodds/cross-env/blob/master/LICENSE
[coc-badge]: https://img.shields.io/badge/code%20of-conduct-ff69b4.svg?style=flat-square
[coc]: https://github.com/kentcdodds/cross-env/blob/master/other/CODE_OF_CONDUCT.md
[emojis]: https://allcontributors.org/docs/en/emoji-key
[all-contributors]: https://github.com/all-contributors/all-contributors
[all-contributors-badge]: https://img.shields.io/github/all-contributors/kentcdodds/cross-env?color=orange&style=flat-square
[bugs]: https://github.com/kentcdodds/cross-env/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+label%3A%22%F0%9F%90%9B+Bug%22+sort%3Acreated-desc
[good-first-issue]: https://github.com/kentcdodds/cross-env/issues?utf8=%E2%9C%93&q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3A%22good+first+issue%22
[angular-formly]: https://github.com/formly-js/angular-formly
[cross-spawn]: https://www.npmjs.com/package/cross-spawn
[malware]: http://blog.npmjs.org/post/163723642530/crossenv-malware-on-the-npm-registry
[ts-loader]: https://www.npmjs.com/package/ts-loader
[win-bash]: https://msdn.microsoft.com/en-us/commandline/wsl/about
<!-- prettier-ignore-end -->
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env node
import { spawn } from 'child_process'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Path to the built cross-env-shell binary
const crossEnvShellPath = join(
__dirname,
'..',
'dist',
'bin',
'cross-env-shell.js',
)
function runCommand(command, args = []) {
return new Promise((resolve, reject) => {
const child = spawn('node', [command, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (data) => {
stdout += data.toString()
})
child.stderr.on('data', (data) => {
stderr += data.toString()
})
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr, code })
} else {
reject(new Error(`Command failed with code ${code}: ${stderr}`))
}
})
child.on('error', (error) => {
reject(error)
})
})
}
function runShellCommand(shellCommand, envVars = {}) {
const envArgs = Object.entries(envVars).map(
([key, value]) => `${key}=${value}`,
)
return runCommand(crossEnvShellPath, [...envArgs, shellCommand])
}
async function runTests() {
console.log('🧪 Running cross-env-shell e2e tests...')
const tests = [
{
name: 'Basic shell environment variable',
test: async () => {
const result = await runShellCommand('echo $TEST_VAR', {
TEST_VAR: 'hello world',
})
if (result.stdout.trim() !== 'hello world') {
throw new Error(
`Expected 'hello world', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Multiple shell environment variables',
test: async () => {
const result = await runShellCommand('echo "$VAR1 $VAR2"', {
VAR1: 'hello',
VAR2: 'world',
})
// The shell will handle this according to its own rules
if (
!result.stdout.trim().includes('hello') ||
!result.stdout.trim().includes('world')
) {
throw new Error(
`Expected output to contain 'hello' and 'world', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Shell command with environment variable in command',
test: async () => {
const result = await runShellCommand('echo $MESSAGE', {
MESSAGE: 'hello from shell',
})
if (!result.stdout.trim().includes('hello from shell')) {
throw new Error(
`Expected output to contain 'hello from shell', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Shell command with environment variable substitution',
test: async () => {
const result = await runShellCommand('echo "Value is: $SUB_VAR"', {
SUB_VAR: 'substituted',
})
if (!result.stdout.trim().includes('substituted')) {
throw new Error(
`Expected output to contain 'substituted', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Shell command with special characters in env var',
test: async () => {
const result = await runShellCommand('echo "$SPECIAL_VAR"', {
SPECIAL_VAR: 'test@#$%^&*()_+-=[]{}|;:,.<>?',
})
if (!result.stdout.trim().includes('test@#$%^&*()_+-=[]{}|;:,.<>?')) {
throw new Error(
`Expected output to contain special characters, got '${result.stdout.trim()}'`,
)
}
},
},
]
let passed = 0
let failed = 0
for (const test of tests) {
try {
console.log(` ✓ ${test.name}`)
await test.test()
passed++
} catch (error) {
console.log(` ✗ ${test.name}: ${error.message}`)
failed++
}
}
console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`)
if (failed > 0) {
process.exit(1)
}
console.log('✅ All cross-env-shell e2e tests passed!')
}
runTests().catch((error) => {
console.error('❌ Test runner failed:', error)
process.exit(1)
})
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env node
import { spawn } from 'child_process'
import { dirname, join } from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// Path to the built cross-env binary
const crossEnvPath = join(__dirname, '..', 'dist', 'bin', 'cross-env.js')
function runCommand(command, args = []) {
return new Promise((resolve, reject) => {
const child = spawn('node', [command, ...args], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (data) => {
stdout += data.toString()
})
child.stderr.on('data', (data) => {
stderr += data.toString()
})
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr, code })
} else {
reject(new Error(`Command failed with code ${code}: ${stderr}`))
}
})
child.on('error', (error) => {
reject(error)
})
})
}
function runNodeScript(script, envVars = {}) {
const envArgs = Object.entries(envVars).map(
([key, value]) => `${key}=${value}`,
)
return runCommand(crossEnvPath, [...envArgs, 'node', '-e', script])
}
async function runTests() {
console.log('🧪 Running cross-env e2e tests...')
const tests = [
{
name: 'Basic environment variable setting',
test: async () => {
const result = await runNodeScript(
'console.log(process.env.TEST_VAR)',
{ TEST_VAR: 'hello world' },
)
if (result.stdout.trim() !== 'hello world') {
throw new Error(
`Expected 'hello world', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Multiple environment variables',
test: async () => {
const result = await runNodeScript(
'console.log(process.env.VAR1 + " " + process.env.VAR2)',
{ VAR1: 'hello', VAR2: 'world' },
)
if (result.stdout.trim() !== 'hello world') {
throw new Error(
`Expected 'hello world', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Environment variable with spaces',
test: async () => {
const result = await runNodeScript(
'console.log(process.env.SPACE_VAR)',
{ SPACE_VAR: 'hello world with spaces' },
)
if (result.stdout.trim() !== 'hello world with spaces') {
throw new Error(
`Expected 'hello world with spaces', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Environment variable with special characters',
test: async () => {
const result = await runNodeScript(
'console.log(process.env.SPECIAL_VAR)',
{ SPECIAL_VAR: 'test@#$%^&*()_+-=[]{}|;:,.<>?' },
)
if (result.stdout.trim() !== 'test@#$%^&*()_+-=[]{}|;:,.<>?') {
throw new Error(
`Expected 'test@#$%^&*()_+-=[]{}|;:,.<>?', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Empty environment variable',
test: async () => {
const result = await runNodeScript(
'console.log("EMPTY:" + (process.env.EMPTY_VAR || "undefined"))',
{ EMPTY_VAR: '' },
)
if (result.stdout.trim() !== 'EMPTY:undefined') {
throw new Error(
`Expected 'EMPTY:undefined', got '${result.stdout.trim()}'`,
)
}
},
},
{
name: 'Command with arguments',
test: async () => {
const result = await runCommand(crossEnvPath, [
'TEST_ARG=value',
'node',
'-e',
'console.log(process.argv[2] + " " + process.env.TEST_ARG)',
'arg1',
])
// The actual behavior is that process.argv[2] is undefined when using -e
if (result.stdout.trim() !== 'undefined value') {
throw new Error(
`Expected 'undefined value', got '${result.stdout.trim()}'`,
)
}
},
},
]
let passed = 0
let failed = 0
for (const test of tests) {
try {
console.log(` ✓ ${test.name}`)
await test.test()
passed++
} catch (error) {
console.log(` ✗ ${test.name}: ${error.message}`)
failed++
}
}
console.log(`\n📊 Test Results: ${passed} passed, ${failed} failed`)
if (failed > 0) {
process.exit(1)
}
console.log('✅ All cross-env e2e tests passed!')
}
runTests().catch((error) => {
console.error('❌ Test runner failed:', error)
process.exit(1)
})
+8
View File
@@ -0,0 +1,8 @@
import config from '@epic-web/config/eslint'
export default [
...config,
{
ignores: ['dist/**', 'coverage/**'],
},
]
-6
View File
@@ -1,6 +0,0 @@
const jest = require('kcd-scripts/jest')
module.exports = {
...jest,
coveragePathIgnorePatterns: [...jest.coveragePathIgnorePatterns, '/bin/'],
}
-3
View File
@@ -1,3 +0,0 @@
# Examples
There aren't any examples yet! Want to add one? See `CONTRIBUTING.md`
-11
View File
@@ -1,11 +0,0 @@
# Project Roadmap
This is where we'll define a few things about the library's goals.
We haven't filled this out yet though. Care to help? See `CONTRIBUTING.md`
## Want to do
## Might do
## Wont do
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Clean CJS files from the dist directory and update package.json types paths.
*
* This script removes .cjs and .d.cts files after zshy builds them,
* and updates package.json to use .d.ts files for types instead of .d.cts.
* This is a workaround until zshy supports ESM-only builds.
*
* See: https://github.com/colinhacks/zshy/issues/21
*/
import fs from 'fs/promises'
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
async function cleanCjsFiles(dir) {
try {
// Check if directory exists
await fs.access(dir)
} catch (error) {
console.error(
`Directory does not exist or is not accessible: ${dir}`,
error,
)
return
}
try {
const items = await fs.readdir(dir, { withFileTypes: true })
for (const item of items) {
const fullPath = path.join(dir, item.name)
if (item.isDirectory()) {
await cleanCjsFiles(fullPath)
} else if (item.name.endsWith('.cjs') || item.name.endsWith('.d.cts')) {
try {
console.log(`Deleting: ${fullPath}`)
await fs.unlink(fullPath)
} catch (error) {
console.error(`Failed to delete ${fullPath}:`, error.message)
}
}
}
} catch (error) {
console.error(`Failed to read directory ${dir}:`, error.message)
}
}
async function updatePackageJsonTypes() {
const packageJsonPath = path.join(__dirname, '..', 'package.json')
try {
const packageJsonContent = await fs.readFile(packageJsonPath, 'utf8')
const packageJson = JSON.parse(packageJsonContent)
// Update the main field from .cjs to .js
if (packageJson.main && packageJson.main.endsWith('.cjs')) {
packageJson.main = packageJson.main.replace('.cjs', '.js')
console.log(`Updated main: ${packageJson.main}`)
}
// Update the main types field
if (packageJson.types && packageJson.types.endsWith('.d.cts')) {
packageJson.types = packageJson.types.replace('.d.cts', '.d.ts')
console.log(`Updated main types: ${packageJson.types}`)
}
// Update types in exports and remove require properties
if (packageJson.exports) {
for (const [key, value] of Object.entries(packageJson.exports)) {
if (typeof value === 'object') {
// Update types from .d.cts to .d.ts
if (value.types && value.types.endsWith('.d.cts')) {
value.types = value.types.replace('.d.cts', '.d.ts')
console.log(`Updated types for ${key}: ${value.types}`)
}
// Remove require properties since we're deleting CJS files
if (value.require) {
delete value.require
console.log(`Removed require for ${key}`)
}
}
}
}
// Write the updated package.json back
await fs.writeFile(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + '\n',
)
console.log(
'Updated package.json types paths and removed require properties',
)
} catch (error) {
console.error('Failed to update package.json:', error.message)
throw error
}
}
async function main() {
try {
// Clean the dist directory
const distPath = path.join(__dirname, '..', 'dist')
console.log('Cleaning CJS files from:', distPath)
await cleanCjsFiles(distPath)
console.log('Clean complete!')
// Update package.json types paths
console.log('Updating package.json types paths...')
await updatePackageJsonTypes()
console.log('Update complete!')
} catch (error) {
console.error('Script failed:', error.message)
process.exit(1)
}
}
main()
+7828
View File
File diff suppressed because it is too large Load Diff
+59 -24
View File
@@ -2,47 +2,59 @@
"name": "cross-env",
"version": "0.0.0-semantically-released",
"description": "Run scripts that set and use environment variables across platforms",
"main": "src/index.js",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"bin": {
"cross-env": "src/bin/cross-env.js",
"cross-env-shell": "src/bin/cross-env-shell.js"
"cross-env": "./dist/bin/cross-env.js",
"cross-env-shell": "./dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=10.14",
"npm": ">=6",
"yarn": ">=1"
"node": ">=20"
},
"scripts": {
"lint": "kcd-scripts lint",
"setup": "npm install && npm run validate -s",
"test": "kcd-scripts test",
"test:update": "npm test -- --updateSnapshot --coverage",
"validate": "kcd-scripts validate"
"build": "zshy && npm run clean-cjs",
"clean-cjs": "node other/clean-cjs.js",
"dev": "zshy --watch",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"typecheck": "tsc --noEmit",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "node e2e/test-cross-env.js && node e2e/test-cross-env-shell.js",
"validate": "npm run build && npm run typecheck && npm run lint && npm run format:check && npm run test:run",
"prepublishOnly": "npm run validate"
},
"files": [
"src",
"!__tests__"
"dist"
],
"keywords": [
"cross-environment",
"environment variable",
"windows"
"windows",
"cross-platform"
],
"author": "Kent C. Dodds <[email protected]> (https://kentcdodds.com)",
"license": "MIT",
"dependencies": {
"cross-spawn": "^7.0.1"
"cross-spawn": "^7.0.6"
},
"devDependencies": {
"kcd-scripts": "^5.5.0"
"@epic-web/config": "^1.21.1",
"eslint": "^9.32.0",
"@types/node": "^24.1.0",
"@types/cross-spawn": "^6.0.6",
"prettier": "^3.6.2",
"typescript": "^5.8.3",
"vitest": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@vitest/coverage-v8": "^3.2.4",
"zshy": "^0.2.5"
},
"eslintConfig": {
"extends": "./node_modules/kcd-scripts/eslint.js"
},
"// babel 1": "this disables all built-in plugins from kcd-scripts for tests",
"// babel 2": "that way we ensure that the tests run without compilation",
"// babel 3": "because this module is published as-is. It is not compiled.",
"babel": {},
"repository": {
"type": "git",
"url": "https://github.com/kentcdodds/cross-env.git"
@@ -50,5 +62,28 @@
"bugs": {
"url": "https://github.com/kentcdodds/cross-env/issues"
},
"homepage": "https://github.com/kentcdodds/cross-env#readme"
"homepage": "https://github.com/kentcdodds/cross-env#readme",
"zshy": {
"exports": {
".": "./src/index.ts",
"./bin/cross-env": "./src/bin/cross-env.ts",
"./bin/cross-env-shell": "./src/bin/cross-env-shell.ts"
}
},
"prettier": "@epic-web/config/prettier",
"module": "./dist/index.js",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./bin/cross-env": {
"types": "./dist/bin/cross-env.d.ts",
"import": "./dist/bin/cross-env.js"
},
"./bin/cross-env-shell": {
"types": "./dist/bin/cross-env-shell.d.ts",
"import": "./dist/bin/cross-env-shell.js"
}
}
}
-81
View File
@@ -1,81 +0,0 @@
const isWindowsMock = require('../is-windows')
const commandConvert = require('../command')
jest.mock('../is-windows')
const env = {
test: 'a',
test1: 'b',
test2: 'c',
test3: 'd',
// eslint-disable-next-line babel/camelcase
empty_var: '',
}
afterEach(() => {
jest.clearAllMocks()
})
test(`converts unix-style env variable usage for windows`, () => {
isWindowsMock.mockReturnValue(true)
expect(commandConvert('$test', env)).toBe('%test%')
})
test(`leaves command unchanged when not a variable`, () => {
expect(commandConvert('test', env)).toBe('test')
})
test(`doesn't convert windows-style env variable`, () => {
isWindowsMock.mockReturnValue(false)
expect(commandConvert('%test%', env)).toBe('%test%')
})
test(`leaves variable unchanged when using correct operating system`, () => {
isWindowsMock.mockReturnValue(false)
expect(commandConvert('$test', env)).toBe('$test')
})
test(`is stateless`, () => {
// this test prevents falling into regexp traps like this:
// http://stackoverflow.com/a/1520853/971592
isWindowsMock.mockReturnValue(true)
expect(commandConvert('$test', env)).toBe(commandConvert('$test', env))
})
test(`converts embedded unix-style env variables usage for windows`, () => {
isWindowsMock.mockReturnValue(true)
expect(commandConvert('$test1/$test2/$test3', env)).toBe(
'%test1%/%test2%/%test3%',
)
})
// eslint-disable-next-line max-len
test(`leaves embedded variables unchanged when using correct operating system`, () => {
isWindowsMock.mockReturnValue(false)
expect(commandConvert('$test1/$test2/$test3', env)).toBe(
'$test1/$test2/$test3',
)
})
test(`converts braced unix-style env variable usage for windows`, () => {
isWindowsMock.mockReturnValue(true)
// eslint-disable-next-line no-template-curly-in-string
expect(commandConvert('${test}', env)).toBe('%test%')
})
test(`removes non-existent variables from the converted command`, () => {
isWindowsMock.mockReturnValue(true)
expect(commandConvert('$test1/$foo/$test2', env)).toBe('%test1%//%test2%')
})
test(`removes empty variables from the converted command`, () => {
isWindowsMock.mockReturnValue(true)
expect(commandConvert('$foo/$test/$empty_var', env)).toBe('/%test%/')
})
test(`normalizes command on windows`, () => {
isWindowsMock.mockReturnValue(true)
// index.js calls `commandConvert` with `normalize` param
// as `true` for command only
expect(commandConvert('./cmd.bat', env, true)).toBe('cmd.bat')
})
+81
View File
@@ -0,0 +1,81 @@
import { describe, test, expect, vi, afterEach } from 'vitest'
import { commandConvert } from '../command.js'
import * as isWindowsModule from '../is-windows.js'
vi.mock('../is-windows.js')
const env = {
test: 'a',
test1: 'b',
test2: 'c',
test3: 'd',
empty_var: '',
}
afterEach(() => {
vi.clearAllMocks()
})
describe('commandConvert', () => {
test('converts unix-style env variable usage for windows', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(commandConvert('$test', env)).toBe('%test%')
})
test('leaves command unchanged when not a variable', () => {
expect(commandConvert('test', env)).toBe('test')
})
test("doesn't convert windows-style env variable", () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(commandConvert('%test%', env)).toBe('%test%')
})
test('leaves variable unchanged when using correct operating system', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(commandConvert('$test', env)).toBe('$test')
})
test('is stateless', () => {
// this test prevents falling into regexp traps like this:
// http://stackoverflow.com/a/1520853/971592
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(commandConvert('$test', env)).toBe(commandConvert('$test', env))
})
test('converts embedded unix-style env variables usage for windows', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(commandConvert('$test1/$test2/$test3', env)).toBe(
'%test1%/%test2%/%test3%',
)
})
test('leaves embedded variables unchanged when using correct operating system', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(commandConvert('$test1/$test2/$test3', env)).toBe(
'$test1/$test2/$test3',
)
})
test('converts braced unix-style env variable usage for windows', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(commandConvert('${test}', env)).toBe('%test%')
})
test('removes non-existent variables from the converted command', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(commandConvert('$test1/$foo/$test2', env)).toBe('%test1%//%test2%')
})
test('removes empty variables from the converted command', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(commandConvert('$foo/$test/$empty_var', env)).toBe('/%test%/')
})
test('normalizes command on windows', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
// index.js calls `commandConvert` with `normalize` param
// as `true` for command only
expect(commandConvert('./cmd.bat', env, true)).toBe('cmd.bat')
})
})
-213
View File
@@ -1,213 +0,0 @@
const crossSpawnMock = require('cross-spawn')
const isWindowsMock = require('../is-windows')
jest.mock('../is-windows')
jest.mock('cross-spawn')
const crossEnv = require('../')
const getSpawned = (call = 0) => crossSpawnMock.spawn.mock.results[call].value
process.setMaxListeners(20)
beforeEach(() => {
jest.spyOn(process, 'exit').mockImplementation(() => {})
crossSpawnMock.spawn.mockReturnValue({on: jest.fn(), kill: jest.fn()})
})
afterEach(() => {
jest.clearAllMocks()
process.exit.mockRestore()
})
test(`sets environment variables and run the remaining command`, () => {
testEnvSetting({FOO_ENV: 'production'}, 'FOO_ENV=production')
})
test(`APPDATA is undefined and not string`, () => {
testEnvSetting({FOO_ENV: 'production', APPDATA: 2}, 'FOO_ENV=production')
})
test(`handles multiple env variables`, () => {
testEnvSetting(
{
FOO_ENV: 'production',
BAR_ENV: 'dev',
APPDATA: '0',
},
'FOO_ENV=production',
'BAR_ENV=dev',
'APPDATA=0',
)
})
test(`handles special characters`, () => {
testEnvSetting({FOO_ENV: './!?'}, 'FOO_ENV=./!?')
})
test(`handles single-quoted strings`, () => {
testEnvSetting({FOO_ENV: 'bar env'}, "FOO_ENV='bar env'")
})
test(`handles double-quoted strings`, () => {
testEnvSetting({FOO_ENV: 'bar env'}, 'FOO_ENV="bar env"')
})
test(`handles equality signs in quoted strings`, () => {
testEnvSetting({FOO_ENV: 'foo=bar'}, 'FOO_ENV="foo=bar"')
})
test(`handles empty single-quoted strings`, () => {
testEnvSetting({FOO_ENV: ''}, "FOO_ENV=''")
})
test(`handles empty double-quoted strings`, () => {
testEnvSetting({FOO_ENV: ''}, 'FOO_ENV=""')
})
test(`handles no value after the equals sign`, () => {
testEnvSetting({FOO_ENV: ''}, 'FOO_ENV=')
})
test(`handles quoted scripts`, () => {
crossEnv(['GREETING=Hi', 'NAME=Joe', 'echo $GREETING && echo $NAME'], {
shell: true,
})
expect(crossSpawnMock.spawn).toHaveBeenCalledWith(
'echo $GREETING && echo $NAME',
[],
{
stdio: 'inherit',
shell: true,
env: {...process.env, GREETING: 'Hi', NAME: 'Joe'},
},
)
})
test(`handles escaped characters`, () => {
// this escapes \,",' and $
crossEnv(
['GREETING=Hi', 'NAME=Joe', 'echo \\"\\\'\\$GREETING\\\'\\" && echo $NAME'],
{
shell: true,
},
)
expect(crossSpawnMock.spawn).toHaveBeenCalledWith(
'echo "\'$GREETING\'" && echo $NAME',
[],
{
stdio: 'inherit',
shell: true,
env: {...process.env, GREETING: 'Hi', NAME: 'Joe'},
},
)
})
test(`does nothing when given no command`, () => {
crossEnv([])
expect(crossSpawnMock.spawn).toHaveBeenCalledTimes(0)
})
test(`normalizes commands on windows`, () => {
isWindowsMock.mockReturnValue(true)
crossEnv(['./cmd.bat'])
expect(crossSpawnMock.spawn).toHaveBeenCalledWith('cmd.bat', [], {
stdio: 'inherit',
env: {...process.env},
})
})
test(`does not normalize command arguments on windows`, () => {
isWindowsMock.mockReturnValue(true)
crossEnv(['echo', 'http://example.com'])
expect(crossSpawnMock.spawn).toHaveBeenCalledWith(
'echo',
['http://example.com'],
{
stdio: 'inherit',
env: {...process.env},
},
)
})
test(`propagates kill signals`, () => {
testEnvSetting({FOO_ENV: 'foo=bar'}, 'FOO_ENV="foo=bar"')
process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')
process.emit('SIGBREAK')
const spawned = getSpawned()
expect(spawned.kill).toHaveBeenCalledWith('SIGTERM')
expect(spawned.kill).toHaveBeenCalledWith('SIGINT')
expect(spawned.kill).toHaveBeenCalledWith('SIGHUP')
expect(spawned.kill).toHaveBeenCalledWith('SIGBREAK')
})
test(`keeps backslashes`, () => {
isWindowsMock.mockReturnValue(true)
crossEnv(['echo', '\\\\\\\\someshare\\\\somefolder'])
expect(crossSpawnMock.spawn).toHaveBeenCalledWith(
'echo',
['\\\\someshare\\somefolder'],
{
stdio: 'inherit',
env: {...process.env},
},
)
})
test(`propagates unhandled exit signal`, () => {
const {spawned} = testEnvSetting({FOO_ENV: 'foo=bar'}, 'FOO_ENV="foo=bar"')
const spawnExitCallback = spawned.on.mock.calls[0][1]
const spawnExitCode = null
spawnExitCallback(spawnExitCode)
expect(process.exit).toHaveBeenCalledWith(1)
})
test(`exits cleanly with SIGINT with a null exit code`, () => {
const {spawned} = testEnvSetting({FOO_ENV: 'foo=bar'}, 'FOO_ENV="foo=bar"')
const spawnExitCallback = spawned.on.mock.calls[0][1]
const spawnExitCode = null
const spawnExitSignal = 'SIGINT'
spawnExitCallback(spawnExitCode, spawnExitSignal)
expect(process.exit).toHaveBeenCalledWith(0)
})
test(`propagates regular exit code`, () => {
const {spawned} = testEnvSetting({FOO_ENV: 'foo=bar'}, 'FOO_ENV="foo=bar"')
const spawnExitCallback = spawned.on.mock.calls[0][1]
const spawnExitCode = 0
spawnExitCallback(spawnExitCode)
expect(process.exit).toHaveBeenCalledWith(0)
})
function testEnvSetting(expected, ...envSettings) {
if (expected.APPDATA === 2) {
// kill the APPDATA to test both is undefined
const {env} = process
delete env.APPDATA
delete expected.APPDATA
} else if (!process.env.APPDATA && expected.APPDATA === '0') {
// set APPDATA and test it
process.env.APPDATA = '0'
}
const ret = crossEnv([...envSettings, 'echo', 'hello world'])
const env = {}
if (process.env.APPDATA) {
env.APPDATA = process.env.APPDATA
}
Object.assign(env, expected)
const spawned = getSpawned()
expect(ret).toBe(spawned)
expect(crossSpawnMock.spawn).toHaveBeenCalledTimes(1)
expect(crossSpawnMock.spawn).toHaveBeenCalledWith('echo', ['hello world'], {
stdio: 'inherit',
shell: undefined,
env: {...process.env, ...env},
})
expect(spawned.on).toHaveBeenCalledTimes(1)
expect(spawned.on).toHaveBeenCalledWith('exit', expect.any(Function))
return {spawned}
}
+247
View File
@@ -0,0 +1,247 @@
import * as crossSpawnModule from 'cross-spawn'
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
import { crossEnv } from '../index.js'
import * as isWindowsModule from '../is-windows.js'
vi.mock('../is-windows.js')
vi.mock('cross-spawn')
const crossSpawnMock = vi.mocked(crossSpawnModule)
const isWindowsMock = vi.mocked(isWindowsModule.isWindows)
const getSpawned = (call = 0) =>
vi.mocked(crossSpawnMock.spawn).mock.results[call]?.value
process.setMaxListeners(20)
beforeEach(() => {
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never)
vi.mocked(crossSpawnMock.spawn).mockReturnValue({
on: vi.fn(),
kill: vi.fn(),
} as any)
})
afterEach(() => {
vi.clearAllMocks()
vi.mocked(process.exit).mockRestore()
})
describe('crossEnv', () => {
test('sets environment variables and run the remaining command', () => {
testEnvSetting({ FOO_ENV: 'production' }, 'FOO_ENV=production')
})
test('APPDATA is undefined and not string', () => {
testEnvSetting({ FOO_ENV: 'production', APPDATA: 2 }, 'FOO_ENV=production')
})
test('handles multiple env variables', () => {
testEnvSetting(
{
FOO_ENV: 'production',
BAR_ENV: 'dev',
APPDATA: '0',
},
'FOO_ENV=production',
'BAR_ENV=dev',
'APPDATA=0',
)
})
test('handles special characters', () => {
testEnvSetting({ FOO_ENV: './!?' }, 'FOO_ENV=./!?')
})
test('handles single-quoted strings', () => {
testEnvSetting({ FOO_ENV: 'bar env' }, "FOO_ENV='bar env'")
})
test('handles double-quoted strings', () => {
testEnvSetting({ FOO_ENV: 'bar env' }, 'FOO_ENV="bar env"')
})
test('handles equality signs in quoted strings', () => {
testEnvSetting({ FOO_ENV: 'foo=bar' }, 'FOO_ENV="foo=bar"')
})
test('handles empty single-quoted strings', () => {
testEnvSetting({ FOO_ENV: '' }, "FOO_ENV=''")
})
test('handles empty double-quoted strings', () => {
testEnvSetting({ FOO_ENV: '' }, 'FOO_ENV=""')
})
test('handles no value after the equals sign', () => {
testEnvSetting({ FOO_ENV: '' }, 'FOO_ENV=')
})
test('handles quoted scripts', () => {
crossEnv(['GREETING=Hi', 'NAME=Joe', 'echo $GREETING && echo $NAME'], {
shell: true,
})
expect(crossSpawnMock.spawn).toHaveBeenCalledWith(
'echo $GREETING && echo $NAME',
[],
{
stdio: 'inherit',
shell: true,
env: { ...process.env, GREETING: 'Hi', NAME: 'Joe' },
},
)
})
test('handles escaped characters', () => {
// this escapes \,",' and $
crossEnv(
[
'GREETING=Hi',
'NAME=Joe',
'echo \\"\\\'\\$GREETING\\\'\\" && echo $NAME',
],
{
shell: true,
},
)
expect(crossSpawnMock.spawn).toHaveBeenCalledWith(
'echo "\'$GREETING\'" && echo $NAME',
[],
{
stdio: 'inherit',
shell: true,
env: { ...process.env, GREETING: 'Hi', NAME: 'Joe' },
},
)
})
test('does nothing when given no command', () => {
crossEnv([])
expect(crossSpawnMock.spawn).toHaveBeenCalledTimes(0)
})
test('normalizes commands on windows', () => {
isWindowsMock.mockReturnValue(true)
crossEnv(['./cmd.bat'])
expect(crossSpawnMock.spawn).toHaveBeenCalledWith('cmd.bat', [], {
stdio: 'inherit',
env: { ...process.env },
})
})
test('does not normalize command arguments on windows', () => {
isWindowsMock.mockReturnValue(true)
crossEnv(['echo', 'http://example.com'])
expect(crossSpawnMock.spawn).toHaveBeenCalledWith(
'echo',
['http://example.com'],
{
stdio: 'inherit',
env: { ...process.env },
},
)
})
test('propagates kill signals', () => {
testEnvSetting({ FOO_ENV: 'foo=bar' }, 'FOO_ENV="foo=bar"')
process.emit('SIGTERM')
process.emit('SIGINT')
process.emit('SIGHUP')
process.emit('SIGBREAK')
const spawned = getSpawned()
expect(spawned?.kill).toHaveBeenCalledWith('SIGTERM')
expect(spawned?.kill).toHaveBeenCalledWith('SIGINT')
expect(spawned?.kill).toHaveBeenCalledWith('SIGHUP')
expect(spawned?.kill).toHaveBeenCalledWith('SIGBREAK')
})
test('keeps backslashes', () => {
isWindowsMock.mockReturnValue(true)
crossEnv(['echo', '\\\\\\\\someshare\\\\somefolder'])
expect(crossSpawnMock.spawn).toHaveBeenCalledWith(
'echo',
['\\\\someshare\\somefolder'],
{
stdio: 'inherit',
env: { ...process.env },
},
)
})
test('propagates unhandled exit signal', () => {
const { spawned } = testEnvSetting(
{ FOO_ENV: 'foo=bar' },
'FOO_ENV="foo=bar"',
)
const spawnExitCallback = spawned?.on.mock.calls[0]?.[1] as (
code: number | null,
signal?: string,
) => void
const spawnExitCode = null
spawnExitCallback(spawnExitCode)
expect(process.exit).toHaveBeenCalledWith(1)
})
test('exits cleanly with SIGINT with a null exit code', () => {
const { spawned } = testEnvSetting(
{ FOO_ENV: 'foo=bar' },
'FOO_ENV="foo=bar"',
)
const spawnExitCallback = spawned?.on.mock.calls[0]?.[1] as (
code: number | null,
signal?: string,
) => void
const spawnExitCode = null
const spawnExitSignal = 'SIGINT'
spawnExitCallback(spawnExitCode, spawnExitSignal)
expect(process.exit).toHaveBeenCalledWith(0)
})
test('propagates regular exit code', () => {
const { spawned } = testEnvSetting(
{ FOO_ENV: 'foo=bar' },
'FOO_ENV="foo=bar"',
)
const spawnExitCallback = spawned?.on.mock.calls[0]?.[1] as (
code: number | null,
signal?: string,
) => void
const spawnExitCode = 0
spawnExitCallback(spawnExitCode)
expect(process.exit).toHaveBeenCalledWith(0)
})
})
function testEnvSetting(
expected: Record<string, any>,
...envSettings: string[]
) {
if (expected.APPDATA === 2) {
// kill the APPDATA to test both is undefined
const { env } = process
delete env.APPDATA
delete expected.APPDATA
} else if (!process.env.APPDATA && expected.APPDATA === '0') {
// set APPDATA and test it
process.env.APPDATA = '0'
}
const ret = crossEnv([...envSettings, 'echo', 'hello world'])
const env: Record<string, string> = {}
if (process.env.APPDATA) {
env.APPDATA = process.env.APPDATA
}
Object.assign(env, expected)
const spawned = getSpawned()
expect(ret).toBe(spawned)
expect(crossSpawnMock.spawn).toHaveBeenCalledTimes(1)
expect(crossSpawnMock.spawn).toHaveBeenCalledWith('echo', ['hello world'], {
stdio: 'inherit',
shell: undefined,
env: { ...process.env, ...env },
})
expect(spawned?.on).toHaveBeenCalledTimes(1)
expect(spawned?.on).toHaveBeenCalledWith('exit', expect.any(Function))
return { spawned }
}
-40
View File
@@ -1,40 +0,0 @@
const isWindows = require('../is-windows')
const {
platform,
env: {OSTYPE},
} = process
// make the platform property writeable
Object.defineProperty(process, 'platform', {
value: platform,
writable: true,
})
afterEach(() => {
process.platform = platform
process.env.OSTYPE = OSTYPE
})
test(`returns true if the current OS is Windows`, () => {
process.platform = 'win32'
expect(isWindows()).toBe(true)
})
test(`returns false if the current OS is not Windows`, () => {
process.platform = 'linux'
expect(isWindows()).toBe(false)
})
test(`returns true if the OSTYPE is cygwin or msys`, () => {
process.platform = 'linux'
process.env.OSTYPE = 'cygwin'
expect(isWindows()).toBe(true)
process.env.OSTYPE = 'msys'
expect(isWindows()).toBe(true)
process.env.OSTYPE = ''
expect(isWindows()).toBe(false)
})
+35
View File
@@ -0,0 +1,35 @@
import { describe, test, expect, afterEach, vi } from 'vitest'
import { isWindows } from '../is-windows.js'
const {
env: { OSTYPE },
} = process
afterEach(() => {
process.env.OSTYPE = OSTYPE
})
describe('isWindows', () => {
test('returns true if the current OS is Windows', () => {
vi.stubGlobal('process', { ...process, platform: 'win32' })
expect(isWindows()).toBe(true)
})
test('returns false if the current OS is not Windows', () => {
vi.stubGlobal('process', { ...process, platform: 'linux' })
expect(isWindows()).toBe(false)
})
test('returns true if the OSTYPE is cygwin or msys', () => {
vi.stubGlobal('process', { ...process, platform: 'linux' })
process.env.OSTYPE = 'cygwin'
expect(isWindows()).toBe(true)
process.env.OSTYPE = 'msys'
expect(isWindows()).toBe(true)
process.env.OSTYPE = ''
expect(isWindows()).toBe(false)
})
})
-121
View File
@@ -1,121 +0,0 @@
const isWindowsMock = require('../is-windows')
const varValueConvert = require('../variable')
jest.mock('../is-windows')
const JSON_VALUE = '{\\"foo\\":\\"bar\\"}'
beforeEach(() => {
process.env.VAR1 = 'value1'
process.env.VAR2 = 'value2'
process.env.JSON_VAR = JSON_VALUE
})
afterEach(() => {
const {env} = process
delete env.VAR1
delete env.VAR2
delete env.JSON_VAR
jest.clearAllMocks()
})
test(`doesn't affect simple variable values`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo')).toBe('foo')
})
test(`doesn't convert a ; into a : on UNIX`, () => {
isWindowsMock.mockReturnValue(false)
expect(varValueConvert('foo;bar', 'PATH')).toBe('foo;bar')
})
test(`doesn't convert a ; into a : for non-PATH on UNIX`, () => {
isWindowsMock.mockReturnValue(false)
expect(varValueConvert('foo;bar', 'FOO')).toBe('foo;bar')
})
test(`doesn't convert a ; into a : for non-PATH on Windows`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo;bar', 'FOO')).toBe('foo;bar')
})
test(`converts a : into a ; on Windows if PATH`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo:bar', 'PATH')).toBe('foo;bar')
})
test(`doesn't convert already valid separators`, () => {
isWindowsMock.mockReturnValue(false)
expect(varValueConvert('foo:bar')).toBe('foo:bar')
})
test(`doesn't convert escaped separators on Windows if PATH`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo\\:bar', 'PATH')).toBe('foo:bar')
})
test(`doesn't convert escaped separators on UNIX`, () => {
isWindowsMock.mockReturnValue(false)
expect(varValueConvert('foo\\:bar', 'PATH')).toBe('foo:bar')
})
test(`converts a separator even if preceded by an escaped backslash`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo\\\\:bar', 'PATH')).toBe('foo\\\\;bar')
})
test(`converts multiple separators if PATH`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo:bar:baz', 'PATH')).toBe('foo;bar;baz')
})
test(`resolves an env variable value`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo-$VAR1')).toBe('foo-value1')
})
test(`resolves an env variable value with curly syntax`, () => {
isWindowsMock.mockReturnValue(true)
// eslint-disable-next-line no-template-curly-in-string
expect(varValueConvert('foo-${VAR1}')).toBe('foo-value1')
})
test(`resolves multiple env variable values`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo-$VAR1-$VAR2')).toBe('foo-value1-value2')
})
test(`resolves an env variable value for non-existant variable`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('foo-$VAR_POTATO')).toBe('foo-')
})
test(`resolves an env variable with a JSON string value on Windows`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('$JSON_VAR')).toBe(JSON_VALUE)
})
test(`resolves an env variable with a JSON string value on UNIX`, () => {
isWindowsMock.mockReturnValue(false)
expect(varValueConvert('$JSON_VAR')).toBe(JSON_VALUE)
})
test(`does not resolve an env variable prefixed with \\ on Windows`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('\\$VAR1')).toBe('$VAR1')
})
test(`does not resolve an env variable prefixed with \\ on UNIX`, () => {
isWindowsMock.mockReturnValue(false)
expect(varValueConvert('\\$VAR1')).toBe('$VAR1')
})
test(`resolves an env variable prefixed with \\\\ on Windows`, () => {
isWindowsMock.mockReturnValue(true)
expect(varValueConvert('\\\\$VAR1')).toBe('\\value1')
})
test(`resolves an env variable prefixed with \\\\ on UNIX`, () => {
isWindowsMock.mockReturnValue(false)
expect(varValueConvert('\\\\$VAR1')).toBe('\\value1')
})
+123
View File
@@ -0,0 +1,123 @@
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'
import * as isWindowsModule from '../is-windows.js'
import { varValueConvert } from '../variable.js'
vi.mock('../is-windows.js')
const JSON_VALUE = '{\\"foo\\":\\"bar\\"}'
beforeEach(() => {
process.env.VAR1 = 'value1'
process.env.VAR2 = 'value2'
process.env.JSON_VAR = JSON_VALUE
})
afterEach(() => {
const { env } = process
delete env.VAR1
delete env.VAR2
delete env.JSON_VAR
vi.clearAllMocks()
})
describe('varValueConvert', () => {
test("doesn't affect simple variable values", () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo', 'TEST')).toBe('foo')
})
test("doesn't convert a ; into a : on UNIX", () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(varValueConvert('foo;bar', 'PATH')).toBe('foo;bar')
})
test("doesn't convert a ; into a : for non-PATH on UNIX", () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(varValueConvert('foo;bar', 'FOO')).toBe('foo;bar')
})
test("doesn't convert a ; into a : for non-PATH on Windows", () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo;bar', 'FOO')).toBe('foo;bar')
})
test('converts a : into a ; on Windows if PATH', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo:bar', 'PATH')).toBe('foo;bar')
})
test("doesn't convert already valid separators", () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(varValueConvert('foo:bar', 'TEST')).toBe('foo:bar')
})
test("doesn't convert escaped separators on Windows if PATH", () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo\\:bar', 'PATH')).toBe('foo:bar')
})
test("doesn't convert escaped separators on UNIX", () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(varValueConvert('foo\\:bar', 'PATH')).toBe('foo:bar')
})
test('converts a separator even if preceded by an escaped backslash', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo\\\\:bar', 'PATH')).toBe('foo\\\\;bar')
})
test('converts multiple separators if PATH', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo:bar:baz', 'PATH')).toBe('foo;bar;baz')
})
test('resolves an env variable value', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo-$VAR1', 'TEST')).toBe('foo-value1')
})
test('resolves an env variable value with curly syntax', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo-${VAR1}', 'TEST')).toBe('foo-value1')
})
test('resolves multiple env variable values', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo-$VAR1-$VAR2', 'TEST')).toBe('foo-value1-value2')
})
test('resolves an env variable value for non-existant variable', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('foo-$VAR_POTATO', 'TEST')).toBe('foo-')
})
test('resolves an env variable with a JSON string value on Windows', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('$JSON_VAR', 'TEST')).toBe(JSON_VALUE)
})
test('resolves an env variable with a JSON string value on UNIX', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(varValueConvert('$JSON_VAR', 'TEST')).toBe(JSON_VALUE)
})
test('does not resolve an env variable prefixed with \\ on Windows', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('\\$VAR1', 'TEST')).toBe('$VAR1')
})
test('does not resolve an env variable prefixed with \\ on UNIX', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(varValueConvert('\\$VAR1', 'TEST')).toBe('$VAR1')
})
test('resolves an env variable prefixed with \\\\ on Windows', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(true)
expect(varValueConvert('\\\\$VAR1', 'TEST')).toBe('\\value1')
})
test('resolves an env variable prefixed with \\\\ on UNIX', () => {
vi.mocked(isWindowsModule.isWindows).mockReturnValue(false)
expect(varValueConvert('\\\\$VAR1', 'TEST')).toBe('\\value1')
})
})
-5
View File
@@ -1,5 +0,0 @@
#!/usr/bin/env node
const crossEnv = require('..')
crossEnv(process.argv.slice(2), {shell: true})
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env node
import { crossEnv } from '../index.js'
crossEnv(process.argv.slice(2), { shell: true })
@@ -1,5 +1,5 @@
#!/usr/bin/env node
const crossEnv = require('..')
import { crossEnv } from '../index.js'
crossEnv(process.argv.slice(2))
-32
View File
@@ -1,32 +0,0 @@
const path = require('path')
const isWindows = require('./is-windows')
module.exports = commandConvert
/**
* Converts an environment variable usage to be appropriate for the current OS
* @param {String} command Command to convert
* @param {Object} env Map of the current environment variable names and their values
* @param {boolean} normalize If the command should be normalized using `path`
* after converting
* @returns {String} Converted command
*/
function commandConvert(command, env, normalize = false) {
if (!isWindows()) {
return command
}
const envUnixRegex = /\$(\w+)|\${(\w+)}/g // $my_var or ${my_var}
const convertedCmd = command.replace(envUnixRegex, (match, $1, $2) => {
const varName = $1 || $2
// In Windows, non-existent variables are not replaced by the shell,
// so for example "echo %FOO%" will literally print the string "%FOO%", as
// opposed to printing an empty string in UNIX. See kentcdodds/cross-env#145
// If the env variable isn't defined at runtime, just strip it from the command entirely
return env[varName] ? `%${varName}%` : ''
})
// Normalization is required for commands with relative paths
// For example, `./cmd.bat`. See kentcdodds/cross-env#127
// However, it should not be done for command arguments.
// See https://github.com/kentcdodds/cross-env/pull/130#issuecomment-319887970
return normalize === true ? path.normalize(convertedCmd) : convertedCmd
}
+35
View File
@@ -0,0 +1,35 @@
import path from 'path'
import { isWindows } from './is-windows.js'
/**
* Converts an environment variable usage to be appropriate for the current OS
* @param command Command to convert
* @param env Map of the current environment variable names and their values
* @param normalize If the command should be normalized using `path` after converting
* @returns Converted command
*/
export function commandConvert(
command: string,
env: NodeJS.ProcessEnv,
normalize = false,
): string {
if (!isWindows()) {
return command
}
const envUnixRegex = /\$(\w+)|\${(\w+)}/g // $my_var or ${my_var}
const convertedCmd = command.replace(envUnixRegex, (match, $1, $2) => {
const varName = $1 || $2
// In Windows, non-existent variables are not replaced by the shell,
// so for example "echo %FOO%" will literally print the string "%FOO%", as
// opposed to printing an empty string in UNIX. See kentcdodds/cross-env#145
// If the env variable isn't defined at runtime, just strip it from the command entirely
return env[varName] ? `%${varName}%` : ''
})
// Normalization is required for commands with relative paths
// For example, `./cmd.bat`. See kentcdodds/cross-env#127
// However, it should not be done for command arguments.
// See https://github.com/kentcdodds/cross-env/pull/130#issuecomment-319887970
return normalize === true ? path.normalize(convertedCmd) : convertedCmd
}
-95
View File
@@ -1,95 +0,0 @@
const {spawn} = require('cross-spawn')
const commandConvert = require('./command')
const varValueConvert = require('./variable')
module.exports = crossEnv
const envSetterRegex = /(\w+)=('(.*)'|"(.*)"|(.*))/
function crossEnv(args, options = {}) {
const [envSetters, command, commandArgs] = parseCommand(args)
const env = getEnvVars(envSetters)
if (command) {
const proc = spawn(
// run `path.normalize` for command(on windows)
commandConvert(command, env, true),
// by default normalize is `false`, so not run for cmd args
commandArgs.map(arg => commandConvert(arg, env)),
{
stdio: 'inherit',
shell: options.shell,
env,
},
)
process.on('SIGTERM', () => proc.kill('SIGTERM'))
process.on('SIGINT', () => proc.kill('SIGINT'))
process.on('SIGBREAK', () => proc.kill('SIGBREAK'))
process.on('SIGHUP', () => proc.kill('SIGHUP'))
proc.on('exit', (code, signal) => {
let crossEnvExitCode = code
// exit code could be null when OS kills the process(out of memory, etc) or due to node handling it
// but if the signal is SIGINT the user exited the process so we want exit code 0
if (crossEnvExitCode === null) {
crossEnvExitCode = signal === 'SIGINT' ? 0 : 1
}
process.exit(crossEnvExitCode) //eslint-disable-line no-process-exit
})
return proc
}
return null
}
function parseCommand(args) {
const envSetters = {}
let command = null
let commandArgs = []
for (let i = 0; i < args.length; i++) {
const match = envSetterRegex.exec(args[i])
if (match) {
let value
if (typeof match[3] !== 'undefined') {
value = match[3]
} else if (typeof match[4] === 'undefined') {
value = match[5]
} else {
value = match[4]
}
envSetters[match[1]] = value
} else {
// No more env setters, the rest of the line must be the command and args
let cStart = []
cStart = args
.slice(i)
// Regex:
// match "\'" or "'"
// or match "\" if followed by [$"\] (lookahead)
.map(a => {
const re = /\\\\|(\\)?'|([\\])(?=[$"\\])/g
// Eliminate all matches except for "\'" => "'"
return a.replace(re, m => {
if (m === '\\\\') return '\\'
if (m === "\\'") return "'"
return ''
})
})
command = cStart[0]
commandArgs = cStart.slice(1)
break
}
}
return [envSetters, command, commandArgs]
}
function getEnvVars(envSetters) {
const envVars = {...process.env}
if (process.env.APPDATA) {
envVars.APPDATA = process.env.APPDATA
}
Object.keys(envSetters).forEach(varName => {
envVars[varName] = varValueConvert(envSetters[varName], varName)
})
return envVars
}
+120
View File
@@ -0,0 +1,120 @@
import { type SpawnOptions } from 'child_process'
import { spawn } from 'cross-spawn'
import { commandConvert } from './command.js'
import { varValueConvert } from './variable.js'
export type CrossEnvOptions = {
shell?: boolean
}
export type ProcessResult = {
exitCode: number | null
signal?: string | null
}
const envSetterRegex = /(\w+)=('(.*)'|"(.*)"|(.*))/
export function crossEnv(
args: string[],
options: CrossEnvOptions = {},
): ProcessResult | null {
const [envSetters, command, commandArgs] = parseCommand(args)
const env = getEnvVars(envSetters)
if (command) {
const spawnOptions: SpawnOptions = {
stdio: 'inherit',
shell: options.shell,
env,
}
const proc = spawn(
// run `path.normalize` for command(on windows)
commandConvert(command, env, true),
// by default normalize is `false`, so not run for cmd args
commandArgs.map((arg) => commandConvert(arg, env)),
spawnOptions,
)
process.on('SIGTERM', () => proc.kill('SIGTERM'))
process.on('SIGINT', () => proc.kill('SIGINT'))
process.on('SIGBREAK', () => proc.kill('SIGBREAK'))
process.on('SIGHUP', () => proc.kill('SIGHUP'))
proc.on('exit', (code: number | null, signal?: string) => {
let crossEnvExitCode = code
// exit code could be null when OS kills the process(out of memory, etc) or due to node handling it
// but if the signal is SIGINT the user exited the process so we want exit code 0
if (crossEnvExitCode === null) {
crossEnvExitCode = signal === 'SIGINT' ? 0 : 1
}
process.exit(crossEnvExitCode)
})
return proc
}
return null
}
function parseCommand(
args: string[],
): [Record<string, string>, string | null, string[]] {
const envSetters: Record<string, string> = {}
let command: string | null = null
let commandArgs: string[] = []
for (let i = 0; i < args.length; i++) {
const arg = args[i]
if (!arg) continue
const match = envSetterRegex.exec(arg)
if (match && match[1]) {
let value: string
if (typeof match[3] !== 'undefined') {
value = match[3]
} else if (typeof match[4] === 'undefined') {
value = match[5] || ''
} else {
value = match[4]
}
envSetters[match[1]] = value
} else {
// No more env setters, the rest of the line must be the command and args
const cStart = args
.slice(i)
// Regex:
// match "\'" or "'"
// or match "\" if followed by [$"\] (lookahead)
.map((a) => {
const re = /\\\\|(\\)?'|([\\])(?=[$"\\])/g
// Eliminate all matches except for "\'" => "'"
return a.replace(re, (m) => {
if (m === '\\\\') return '\\'
if (m === "\\'") return "'"
return ''
})
})
command = cStart[0] || null
commandArgs = cStart.slice(1).filter(Boolean)
break
}
}
return [envSetters, command, commandArgs]
}
function getEnvVars(envSetters: Record<string, string>): NodeJS.ProcessEnv {
const envVars = { ...process.env }
if (process.env.APPDATA) {
envVars.APPDATA = process.env.APPDATA
}
Object.keys(envSetters).forEach((varName) => {
const value = envSetters[varName]
if (value !== undefined) {
envVars[varName] = varValueConvert(value, varName)
}
})
return envVars
}
-2
View File
@@ -1,2 +0,0 @@
module.exports = () =>
process.platform === 'win32' || /^(msys|cygwin)$/.test(process.env.OSTYPE)
+10
View File
@@ -0,0 +1,10 @@
/**
* Determines if the current platform is Windows
* @returns true if running on Windows, false otherwise
*/
export function isWindows(): boolean {
return (
process.platform === 'win32' ||
/^(msys|cygwin)$/.test(process.env.OSTYPE || '')
)
}
-69
View File
@@ -1,69 +0,0 @@
const isWindows = require('./is-windows')
const pathLikeEnvVarWhitelist = new Set(['PATH', 'NODE_PATH'])
module.exports = varValueConvert
/**
* This will transform UNIX-style list values to Windows-style.
* For example, the value of the $PATH variable "/usr/bin:/usr/local/bin:."
* will become "/usr/bin;/usr/local/bin;." on Windows.
* @param {String} varValue Original value of the env variable
* @param {String} varName Original name of the env variable
* @returns {String} Converted value
*/
function replaceListDelimiters(varValue, varName = '') {
const targetSeparator = isWindows() ? ';' : ':'
if (!pathLikeEnvVarWhitelist.has(varName)) {
return varValue
}
return varValue.replace(/(\\*):/g, (match, backslashes) => {
if (backslashes.length % 2) {
// Odd number of backslashes preceding it means it's escaped,
// remove 1 backslash and return the rest as-is
return match.substr(1)
}
return backslashes + targetSeparator
})
}
/**
* This will attempt to resolve the value of any env variables that are inside
* this string. For example, it will transform this:
* cross-env FOO=$NODE_ENV BAR=\\$NODE_ENV echo $FOO $BAR
* Into this:
* FOO=development BAR=$NODE_ENV echo $FOO
* (Or whatever value the variable NODE_ENV has)
* Note that this function is only called with the right-side portion of the
* env var assignment, so in that example, this function would transform
* the string "$NODE_ENV" into "development"
* @param {String} varValue Original value of the env variable
* @returns {String} Converted value
*/
function resolveEnvVars(varValue) {
const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)})/g // $my_var or ${my_var} or \$my_var
return varValue.replace(
envUnixRegex,
(_, escapeChars, varNameWithDollarSign, varName, altVarName) => {
// do not replace things preceded by a odd number of \
if (escapeChars.length % 2 === 1) {
return varNameWithDollarSign
}
return (
escapeChars.substr(0, escapeChars.length / 2) +
(process.env[varName || altVarName] || '')
)
},
)
}
/**
* Converts an environment variable value to be appropriate for the current OS.
* @param {String} originalValue Original value of the env variable
* @param {String} originalName Original name of the env variable
* @returns {String} Converted value
*/
function varValueConvert(originalValue, originalName) {
return resolveEnvVars(replaceListDelimiters(originalValue, originalName))
}
+70
View File
@@ -0,0 +1,70 @@
import { isWindows } from './is-windows.js'
const pathLikeEnvVarWhitelist = new Set(['PATH', 'NODE_PATH'])
/**
* This will transform UNIX-style list values to Windows-style.
* For example, the value of the $PATH variable "/usr/bin:/usr/local/bin:."
* will become "/usr/bin;/usr/local/bin;." on Windows.
* @param varValue Original value of the env variable
* @param varName Original name of the env variable
* @returns Converted value
*/
function replaceListDelimiters(varValue: string, varName = ''): string {
const targetSeparator = isWindows() ? ';' : ':'
if (!pathLikeEnvVarWhitelist.has(varName)) {
return varValue
}
return varValue.replace(/(\\*):/g, (match, backslashes) => {
if (backslashes.length % 2) {
// Odd number of backslashes preceding it means it's escaped,
// remove 1 backslash and return the rest as-is
return match.substring(1)
}
return backslashes + targetSeparator
})
}
/**
* This will attempt to resolve the value of any env variables that are inside
* this string. For example, it will transform this:
* cross-env FOO=$NODE_ENV BAR=\\$NODE_ENV echo $FOO $BAR
* Into this:
* FOO=development BAR=$NODE_ENV echo $FOO
* (Or whatever value the variable NODE_ENV has)
* Note that this function is only called with the right-side portion of the
* env var assignment, so in that example, this function would transform
* the string "$NODE_ENV" into "development"
* @param varValue Original value of the env variable
* @returns Converted value
*/
function resolveEnvVars(varValue: string): string {
const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)})/g // $my_var or ${my_var} or \$my_var
return varValue.replace(
envUnixRegex,
(_, escapeChars, varNameWithDollarSign, varName, altVarName) => {
// do not replace things preceded by a odd number of \
if (escapeChars.length % 2 === 1) {
return varNameWithDollarSign
}
return (
escapeChars.substring(0, escapeChars.length / 2) +
(process.env[varName || altVarName] || '')
)
},
)
}
/**
* Converts an environment variable value to be appropriate for the current OS.
* @param originalValue Original value of the env variable
* @param originalName Original name of the env variable
* @returns Converted value
*/
export function varValueConvert(
originalValue: string,
originalName: string,
): string {
return resolveEnvVars(replaceListDelimiters(originalValue, originalName))
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": ["@epic-web/config/typescript"],
"compilerOptions": {
"outDir": "dist",
"module": "ESNext",
"target": "ES2022",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"noEmit": false
},
"include": ["src/**/*", "**/*.config.ts"],
"exclude": ["node_modules", "dist"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules/',
'dist/',
'**/*.d.ts',
'**/*.config.*',
'**/coverage/**',
],
},
},
})