diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/.fixture/tsx/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/.fixture/tsx/package.json new file mode 100644 index 0000000000..e900a90163 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/.fixture/tsx/package.json @@ -0,0 +1,4 @@ +{ + "name": "tsx", + "version": "4.20.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/package.json new file mode 100644 index 0000000000..6cc7fefa28 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/package.json @@ -0,0 +1,14 @@ +{ + "name": "migration-tsx", + "type": "module", + "scripts": { + "dev": "tsx watch --clear-screen=false src/server.ts", + "seed": "cross-env NODE_ENV=development npx tsx scripts/seed.ts", + "bench": "node --import tsx ./scripts/bench.ts", + "start": "concurrently \"tsx watch src/server.ts\" \"vp dev\"" + }, + "devDependencies": { + "tsx": "file:.fixture/tsx", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/bench.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/bench.ts new file mode 100644 index 0000000000..52f3425d93 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/bench.ts @@ -0,0 +1 @@ +export const runs: number = 100; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/release.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/release.ts new file mode 100644 index 0000000000..ae8626311e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/release.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env tsx +const version: string = '1.0.0'; +console.log(version); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/seed.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/seed.ts new file mode 100644 index 0000000000..acb8e841ed --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/scripts/seed.ts @@ -0,0 +1 @@ +export const rows: number = 10; diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots.toml new file mode 100644 index 0000000000..d1728c08a3 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots.toml @@ -0,0 +1,31 @@ +[[case]] +name = "migration_tsx_scripts" +vp = "local" +comment = "tsx comes from a local stub package so the install stays offline" +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "tsx scripts should migrate to vpx automatically", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "scripts run vpx, and the tsx devDependency is gone", continue-on-failure = true }, + { argv = ["vpt", "print-file", "scripts/release.ts"], comment = "the shebang runs vpx", continue-on-failure = true }, +] + +[[case]] +name = "migration_tsx_kept" +vp = "local" +comment = "tsx comes from a local stub package so the install stays offline" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "scripts.repl", "tsx"], snapshot = false }, + { argv = ["vpt", "json-edit", "package.json", "scripts.calc", "tsx -e 'console.log(1 + 1)'"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "scripts vpx cannot run keep tsx, with a warning for each", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "the other scripts run vpx, and the tsx devDependency stays", continue-on-failure = true }, +] + +[[case]] +name = "migration_tsx_already_vite_plus" +vp = "local" +comment = "On a project already using Vite+, the tsx migration belongs to `vp migrate --full`." +steps = [ + { argv = ["vpt", "json-edit", "package.json", "devDependencies.vite-plus", "latest"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"], comment = "a plain upgrade leaves tsx scripts to --full", continue-on-failure = true }, + { argv = ["vp", "migrate", "--no-interactive", "--full", "--no-hooks", "--no-agent", "--no-editor"], comment = "--full migrates tsx scripts to vpx", continue-on-failure = true }, + { argv = ["vpt", "print-file", "package.json"], comment = "scripts run vpx, and the tsx devDependency is gone", continue-on-failure = true }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md new file mode 100644 index 0000000000..924cf81287 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_already_vite_plus.md @@ -0,0 +1,59 @@ +# migration_tsx_already_vite_plus + +On a project already using Vite+, the tsx migration belongs to `vp migrate --full`. + +## `vpt json-edit package.json devDependencies.vite-plus latest` + + +## `vp migrate --no-interactive` + +a plain upgrade leaves tsx scripts to --full + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite-plus latest → + vite → +• Package manager settings configured +• Skipped editor, hooks, and lint setup. Run `vp migrate --full` to apply them. +``` + +## `vp migrate --no-interactive --full --no-hooks --no-agent --no-editor` + +--full migrates tsx scripts to vpx + +``` +VITE+ - The Unified Toolchain for the Web + +▲ No package manager is declared in package.json; using pnpm for this migration without adding a pin. Run `vp env pin` to declare it explicitly. +● tsx scripts detected. Auto-migrating to vpx... +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• tsx scripts migrated to `vpx` +``` + +## `vpt print-file package.json` + +scripts run vpx, and the tsx devDependency is gone + +``` +{ + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-tsx", + "scripts": { + "bench": "vpx ./scripts/bench.ts", + "dev": "vpx --watch --clear-screen=false src/server.ts", + "seed": "cross-env NODE_ENV=development vpx scripts/seed.ts", + "start": "concurrently \"vpx --watch src/server.ts\" \"vp dev\"" + }, + "type": "module" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md new file mode 100644 index 0000000000..83ca0a570a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_kept.md @@ -0,0 +1,51 @@ +# migration_tsx_kept + +tsx comes from a local stub package so the install stays offline + +## `vpt json-edit package.json scripts.repl tsx` + + +## `vpt json-edit package.json scripts.calc 'tsx -e '\''console.log(1 + 1)'\'''` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +scripts vpx cannot run keep tsx, with a warning for each + +``` +VITE+ - The Unified Toolchain for the Web + +● tsx scripts detected. Auto-migrating to vpx... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• tsx scripts migrated to `vpx` +! Warnings: + - Kept tsx in package.json scripts "calc": `vpx` does not run TypeScript passed to --eval or --print. + - Kept tsx in package.json scripts "repl": `vpx` does not start a TypeScript REPL. + - Kept the tsx devDependency in package.json: `scripts` in package.json still uses tsx. +``` + +## `vpt print-file package.json` + +the other scripts run vpx, and the tsx devDependency stays + +``` +{ + "devDependencies": { + "tsx": "file:.fixture/tsx", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-tsx", + "scripts": { + "bench": "vpx ./scripts/bench.ts", + "calc": "tsx -e 'console.log(1 + 1)'", + "dev": "vpx --watch --clear-screen=false src/server.ts", + "repl": "tsx", + "seed": "cross-env NODE_ENV=development vpx scripts/seed.ts", + "start": "concurrently \"vpx --watch src/server.ts\" \"vp dev\"" + }, + "type": "module" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md new file mode 100644 index 0000000000..6bbb17a93e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/snapshots/migration_tsx_scripts.md @@ -0,0 +1,48 @@ +# migration_tsx_scripts + +tsx comes from a local stub package so the install stays offline + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +tsx scripts should migrate to vpx automatically + +``` +VITE+ - The Unified Toolchain for the Web + +● tsx scripts detected. Auto-migrating to vpx... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• tsx scripts migrated to `vpx` +``` + +## `vpt print-file package.json` + +scripts run vpx, and the tsx devDependency is gone + +``` +{ + "name": "migration-tsx", + "type": "module", + "scripts": { + "dev": "vpx --watch --clear-screen=false src/server.ts", + "seed": "cross-env NODE_ENV=development vpx scripts/seed.ts", + "bench": "vpx ./scripts/bench.ts", + "start": "concurrently \"vpx --watch src/server.ts\" \"vp dev\"" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` + +## `vpt print-file scripts/release.ts` + +the shebang runs vpx + +``` +#!/usr/bin/env vpx +const version: string = '1.0.0'; +console.log(version); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/src/server.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/src/server.ts new file mode 100644 index 0000000000..dd8aae924a --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_tsx/src/server.ts @@ -0,0 +1 @@ +export const port: number = 3000; diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index 7f46795dcf..503c2f401e 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -18,7 +18,7 @@ Migration is idempotent: running it again after a successful migration should no On a project that already depends on `vite-plus`, `vp migrate` performs an upgrade only: it updates dependencies and package-manager configuration and finalizes imports. It does not touch project setup. -- `--full` also runs the setup actions: git hooks, editor config, agent files, ESLint, Prettier, and tsup migration, framework shims, the tsconfig `baseUrl` fix, and the Volta to `.node-version` conversion. +- `--full` also runs the setup actions: git hooks, editor config, agent files, ESLint, Prettier, tsup, and tsx migration, framework shims, the tsconfig `baseUrl` fix, and the Volta to `.node-version` conversion. - `--hooks`, `--agent`, and `--editor` opt into a single setup action without `--full`. When a default upgrade skips setup actions that would apply, it prints a hint to run `vp migrate --full`. Fresh (non Vite+) projects always run the full migration. @@ -261,8 +261,9 @@ Migration rewrites commands provided by the Vite+ toolchain in `package.json` sc | `eslint` | `vp lint`, when its optional migration runs | | `prettier` | `vp fmt`, when its optional migration runs | | `tsup` | `vp pack`, when its optional migration runs | +| `tsx` | `vpx`, when its optional migration runs | -For commands launched through `bunx`, migration preserves `bunx` and its `--bun` flag (keeping the user's chosen runtime) and rewrites only the managed command. This also works when `bunx` follows a command-launcher delimiter such as `run` or `--`: +For commands launched through `bunx`, migration preserves `bunx` and its `--bun` flag (keeping the user's chosen runtime) and rewrites only the managed command. tsx is the exception: `bunx tsx` becomes `vpx`, and `bunx --bun tsx` keeps tsx (see [tsx to `vpx`](#tsx-to-vpx)). This also works when `bunx` follows a command-launcher delimiter such as `run` or `--`: | Before | After | | ------------------------------------------------------- | -------------------------------------------------------- | @@ -273,6 +274,27 @@ For commands launched through `bunx`, migration preserves `bunx` and its `--bun` Unrelated `bunx` commands and other package-executor forms remain unchanged. +### tsx to `vpx` + +When a `package.json` script, a lint-staged task, or a `#!/usr/bin/env tsx` shebang runs [tsx](https://tsx.is), migration offers to move it to [`vpx`](/guide/vpx), Vite+'s TypeScript script runner. It rewrites scripts and lint-staged tasks in `package.json` and JSON lint-staged files, and `#!/usr/bin/env tsx` shebangs in script files: + +| Before | After | +| -------------------------------------------------------------------------- | ------------------------------------------ | +| `tsx src/seed.ts --rows 10` | `vpx src/seed.ts --rows 10` | +| `tsx watch --include "*.json" src/a.ts` | `vpx --watch --include "*.json" src/a.ts` | +| `npx tsx a.ts`, `pnpm exec tsx a.ts`, `pnpm dlx tsx a.ts`, `bunx tsx a.ts` | `vpx a.ts` | +| `node --import tsx ./a.ts` | `vpx ./a.ts` | +| `cross-env NODE_ENV=dev tsx a.ts` | `cross-env NODE_ENV=dev vpx a.ts` | +| `concurrently "tsx watch a.ts" "vp dev"` | `concurrently "vpx --watch a.ts" "vp dev"` | + +`tsx watch` becomes `vpx --watch`, never `vpx watch`, which runs the npm package `watch`. Node.js options and tsx watch options keep their order, and `--no-cache` is dropped. A quoted command is rewritten only for programs that run it, such as `concurrently`, `nodemon --exec`, `sh -c`, and `cross-env-shell`; a quoted argument of other programs, as in `echo "tsx a.ts"`, is data. `vpx` needs the same Node.js versions as Vite+ itself, and migration already upgrades incompatible runtime pins to them (see [Vitest 5](/guide/vitest-v5)). + +Migration keeps tsx, with a warning, for a command `vpx` cannot run: `--eval`, `--print`, or `--check`, the REPL, `--test` without files or in watch mode, an entry without a file extension, commands that set `TSX_TSCONFIG_PATH` (pass `--tsconfig` to `vpx` instead) or load tsx through `NODE_OPTIONS`, and commands with pipes, variables, or subshells. It also reads the scripts a command runs, including local `--require` and `--import` modules, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, and, because tsx compiles ES module syntax to CommonJS, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. This check skips globs and scripts after a `cd` or another shell state change, and reads only the files a command names, not the modules they import. It does not look for what else `vpx` handles differently, such as standard decorators, tsconfig `paths` in `require()`, or a tsconfig `module` of `node16` or `nodenext`; `vpx` reports those when the script runs. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it too, since `vpx` comes from the `vite-plus` devDependency. + +Migration removes the `tsx` devDependency only when nothing uses tsx anymore: no kept script, no import of `tsx` or its subpaths such as `tsx/esm/api` (in source, build output, or a `package.json` `imports` alias), no shebang, no tool setting such as `--import=tsx` in `package.json` or a JSON or JavaScript config, and no tsx command in CI workflows, git hooks, or shell scripts. The root's dependency stays while any workspace package still uses tsx, and a package's dependency stays while a nested package under it does. When tsx stays, the warning names what still uses it. + +See [Coming from tsx](/guide/vpx#coming-from-tsx) for the other differences. + ## Continuous Integration Rules Migration replaces exact `voidzero-dev/setup-vp@v1` references in GitHub Actions workflows and composite actions under `.github` with the latest exact `setup-vp` release known to that Vite+ version. The frozen `v1` tag does not receive new releases. Existing exact versions and commit SHAs are left unchanged. diff --git a/docs/guide/migrate.md b/docs/guide/migrate.md index 61305d7228..798b48dade 100644 --- a/docs/guide/migrate.md +++ b/docs/guide/migrate.md @@ -8,7 +8,7 @@ import { migrationPrompt, upgradePrompt } from '../.vitepress/theme/data/migrati ## Overview -This command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, Prettier, tsdown, and tsup setups into Vite+. +This command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, Prettier, tsdown, tsup, and tsx setups into Vite+. Use it when you want to take an existing project and move it onto the Vite+ defaults instead of wiring each tool by hand. diff --git a/docs/guide/vpx.md b/docs/guide/vpx.md index fb534ffb50..5f3fdcf4a4 100644 --- a/docs/guide/vpx.md +++ b/docs/guide/vpx.md @@ -183,7 +183,7 @@ JSX that depends on a compiler plugin is not supported. `vpx` compiles JSX only ### Coming from tsx -Most scripts that run with tsx run with `vpx` unchanged. These differences can need a change: +`vp migrate` rewrites tsx commands in `package.json` scripts to `vpx` and removes the `tsx` devDependency once nothing uses it; see [tsx to `vpx`](/guide/migrate-rules#tsx-to-vpx). Most scripts that run with tsx run with `vpx` unchanged. These differences can need a change: - **JSX default**: without a tsconfig `jsx` option, tsx compiles JSX to `React.createElement()`, which needs `React` in scope, while `vpx` uses the automatic runtime. Set `"jsx": "react"` to keep the classic output. Because of its default, tsx also ignores a lone `/** @jsxImportSource … */` comment unless the file has `/** @jsxRuntime automatic */` or the tsconfig sets `"jsx": "react-jsx"`; `vpx` applies the comment on its own. - **`.cts` files with `import` or `export`**: tsx compiles them to CommonJS, and `vpx` reports an error. Rename the file to `.mts`, or write it with `import x = require()` and `export =`. diff --git a/packages/cli/src/migration/__tests__/tsx.spec.ts b/packages/cli/src/migration/__tests__/tsx.spec.ts new file mode 100644 index 0000000000..e66c8f88be --- /dev/null +++ b/packages/cli/src/migration/__tests__/tsx.spec.ts @@ -0,0 +1,745 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn() }, +})); + +import { detectTsxProject, migrateTsxToVpx, rewriteTsxCommand } from '../migrator/tsx.ts'; +import { createMigrationReport } from '../report.ts'; + +describe('rewriteTsxCommand', () => { + it.each([ + ['tsx ./src/seed.ts --flag', 'vpx ./src/seed.ts --flag'], + ['tsx src/seed.ts', 'vpx src/seed.ts'], + ['tsx watch src/server.ts', 'vpx --watch src/server.ts'], + [ + 'tsx watch --clear-screen=false --include "config/*.json" --exclude src/gen.ts --tsconfig tsconfig.app.json src/server.ts --port 3000', + 'vpx --watch --clear-screen=false --include "config/*.json" --exclude src/gen.ts --tsconfig tsconfig.app.json src/server.ts --port 3000', + ], + ['tsx watch --no-cache src/server.ts', 'vpx --watch src/server.ts'], + ['tsx --no-cache ./a.ts', 'vpx ./a.ts'], + ['tsx --no_cache ./a.ts', 'vpx ./a.ts'], + ['tsx --watch ./a.ts', 'vpx --watch ./a.ts'], + ['tsx --env-file=.env --inspect ./a.ts', 'vpx --env-file=.env --inspect ./a.ts'], + ['tsx --env-file .env ./a.ts arg', 'vpx --env-file .env ./a.ts arg'], + ['tsx --test ./a.test.ts', 'vpx --test ./a.test.ts'], + // tsx loading itself again is redundant, and would compete with vpx. + ['tsx --import tsx ./a.ts', 'vpx ./a.ts'], + ['tsx -r tsx/cjs --import ./setup.ts ./a.cts', 'vpx --import ./setup.ts ./a.cts'], + ["tsx 'my script.ts'", "vpx 'my script.ts'"], + ['npx tsx ./a.ts', 'vpx ./a.ts'], + ['npx --yes tsx ./a.ts', 'vpx ./a.ts'], + ['npx -y tsx ./a.ts', 'vpx ./a.ts'], + ['pnpm exec tsx ./a.ts', 'vpx ./a.ts'], + ['pnpm dlx tsx ./a.ts', 'vpx ./a.ts'], + ['pnpm tsx ./a.ts', 'vpx ./a.ts'], + ['yarn tsx watch ./a.ts', 'vpx --watch ./a.ts'], + ['bunx tsx ./a.ts', 'vpx ./a.ts'], + ['NODE_ENV=test tsx ./a.ts', 'NODE_ENV=test vpx ./a.ts'], + ['cross-env NODE_ENV=production tsx ./a.ts', 'cross-env NODE_ENV=production vpx ./a.ts'], + ['tsx ./a.ts && tsx watch ./b.ts; vp build', 'vpx ./a.ts && vpx --watch ./b.ts; vp build'], + ['node --import tsx ./a.ts', 'vpx ./a.ts'], + ['node --import=tsx --inspect ./a.ts', 'vpx --inspect ./a.ts'], + ['node --loader tsx/esm --watch ./a.ts', 'vpx --watch ./a.ts'], + ['node -r tsx/cjs ./a.cts', 'vpx ./a.cts'], + ['node --import tsx --import ./setup.ts ./a.ts', 'vpx --import ./setup.ts ./a.ts'], + [ + 'concurrently "tsx watch src/api.ts" "vp dev"', + 'concurrently "vpx --watch src/api.ts" "vp dev"', + ], + [ + 'concurrently --names "api,web" "tsx watch src/api.ts" "vp dev"', + 'concurrently --names "api,web" "vpx --watch src/api.ts" "vp dev"', + ], + ["nodemon --exec 'tsx src/a.ts'", "nodemon --exec 'vpx src/a.ts'"], + ["sh -c 'tsx src/a.ts && vp build'", "sh -c 'vpx src/a.ts && vp build'"], + ["bash -lc 'tsx src/a.ts'", "bash -lc 'vpx src/a.ts'"], + ["nodemon --watch src --exec 'tsx src/a.ts'", "nodemon --watch src --exec 'vpx src/a.ts'"], + ['cross-env-shell "tsx src/a.ts"', 'cross-env-shell "vpx src/a.ts"'], + ])('rewrites %s', (command, expected) => { + const result = rewriteTsxCommand(command); + expect(result.command).toBe(expected); + expect(result.kept).toEqual([]); + // Running the migration again changes nothing. + expect(rewriteTsxCommand(expected)).toEqual({ command: expected, kept: [] }); + }); + + it.each([ + ['vpx ./a.ts'], + ['vp build'], + ['oxlint src/app.tsx'], + ['node ./a.js'], + ['tsx-foo ./a.ts'], + ])('leaves %s alone', (command) => { + expect(rewriteTsxCommand(command)).toEqual({ command, kept: [] }); + }); + + it.each([ + ["tsx -e 'console.log(1)'", '--eval or --print'], + ['tsx --print 1', '--eval or --print'], + ['tsx -c ./a.js', '-c or --check'], + ['tsx', 'REPL'], + ['tsx watch', 'REPL'], + ['tsx --test', 'list the test files'], + ['tsx src/index', '`src/index` has no file extension'], + ['tsx --watch-path=src ./a.ts', '--watch-path'], + ['tsx --watch_path=src ./a.ts', '--watch-path'], + ['tsx ./a.ts | tee log', 'shell syntax'], + ['tsx $ENTRY', 'shell syntax'], + ['tsx %ENTRY%.ts', 'shell syntax'], + ['.\\node_modules\\.bin\\tsx.cmd scripts/a.ts', 'shell syntax'], + ['node --import tsx', 'REPL'], + ['node --import tsx -e 1', '--eval or --print'], + ['node --import tsx --watch-path src ./a.ts', '--watch-path'], + ["NODE_OPTIONS='--import tsx' node ./a.ts", '`NODE_OPTIONS` loads tsx'], + // A quoted argument of a program that does not run commands is data. + ['echo "tsx scripts/build.ts"', 'does not rewrite this use of tsx'], + // Only the value of `-c` is a command; the next argument is the shell's `$0`. + [`sh -c 'echo "$0"' 'tsx a.ts'`, 'does not rewrite this use of tsx'], + ['TSX_TSCONFIG_PATH=cli.json tsx ./a.ts', 'TSX_TSCONFIG_PATH'], + ['TSX_TSCONFIG_PATH=cli.json node --import tsx ./a.ts', 'TSX_TSCONFIG_PATH'], + ['cross-env-shell TSX_TSCONFIG_PATH=cli.json "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], + ['TSX_TSCONFIG_PATH=cli.json concurrently "tsx ./a.ts"', 'TSX_TSCONFIG_PATH'], + ['NODE_OPTIONS=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], + // Windows reads environment variable names in any case. + ['cross-env node_options=--import=tsx tsx ./a.ts', 'NODE_OPTIONS'], + // Windows also finds the tsx bin as `TSX`. + ['TSX ./a.ts', 'does not rewrite this use of tsx'], + ['export TSX_TSCONFIG_PATH=cli.json && tsx ./a.ts', 'shell syntax'], + ['tsx watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], + ['tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], + ['node --import tsx --watch --test ./a.test.ts', '`vpx --watch` does not run `--test`'], + ])('keeps %s', (command, reason) => { + const result = rewriteTsxCommand(command); + expect(result.command).toBe(command); + expect(result.kept).toEqual([expect.stringContaining(reason)]); + }); + + it('rewrites the parts of a command list that vpx can run', () => { + const result = rewriteTsxCommand("tsx ./a.ts && tsx -e 'run()'"); + expect(result.command).toBe("vpx ./a.ts && tsx -e 'run()'"); + expect(result.kept).toEqual([expect.stringContaining('--eval')]); + }); + + describe('with the scripts it runs', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-tsx-scripts-')); + fs.writeFileSync(path.join(dir, 'package.json'), '{}\n'); + fs.writeFileSync(path.join(dir, 'esm.cts'), 'export const a = 1;\n'); + fs.writeFileSync(path.join(dir, 'cjs.cts'), 'exports.a = 1;\n'); + fs.writeFileSync( + path.join(dir, 'dirname.ts'), + "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n", + ); + fs.writeFileSync( + path.join(dir, 'plain.ts'), + "import fs from 'node:fs';\nfs.readdirSync('.');\n", + ); + fs.writeFileSync( + path.join(dir, 'exports.ts'), + "import fs from 'node:fs';\nmodule.exports = fs.readdirSync('.');\n", + ); + // ES module idioms that only look like CommonJS globals. + fs.writeFileSync( + path.join(dir, 'esm-idioms.ts'), + [ + "import path from 'node:path';", + "import { createRequire } from 'node:module';", + "import { fileURLToPath } from 'node:url';", + 'const __dirname = path.dirname(fileURLToPath(import.meta.url));', + 'const require = createRequire(import.meta.url);', + "const pkg = require(path.join(__dirname, 'package.json'));", + 'console.log(pkg.exports.x);', + '', + ].join('\n'), + ); + // `cd tools` stops the checks; this script would keep tsx otherwise. + fs.mkdirSync(path.join(dir, 'tools')); + fs.writeFileSync( + path.join(dir, 'tools/only.ts'), + "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n", + ); + fs.writeFileSync(path.join(dir, 'tools/plain.ts'), 'export {};\n'); + // tsx resolves `require('./helper.js')` to `helper.ts`; `vpx` does not. + fs.writeFileSync(path.join(dir, 'helper.ts'), 'exports.x = 1;\n'); + fs.writeFileSync(path.join(dir, 'require-js.cts'), "const { x } = require('./helper.js');\n"); + fs.writeFileSync(path.join(dir, 'require-bare.cts'), "const { x } = require('./helper');\n"); + // Compact ES module syntax, and CommonJS that only looks like it. + fs.writeFileSync(path.join(dir, 'compact.ts'), 'export{};console.log(__dirname);\n'); + fs.writeFileSync( + path.join(dir, 'import-equals.cts'), + "import fs = require('node:fs');\nexport = fs.readdirSync(__dirname);\n", + ); + fs.writeFileSync(path.join(dir, 'importer.ts'), 'importer.run(__dirname);\n'); + fs.writeFileSync( + path.join(dir, 'commented.ts'), + "import fs from 'node:fs';\n// const require = createRequire(import.meta.url);\nrequire('./x');\n", + ); + // A CommonJS global named only in a comment is not a use. + fs.writeFileSync( + path.join(dir, 'comment-only.ts'), + "import fs from 'node:fs';\n// no require() or __dirname here\nfs.readdirSync('.');\n", + ); + // A `//` in a string does not start a comment that hides the rest of the line. + fs.writeFileSync( + path.join(dir, 'string-url.ts'), + "import fs from 'node:fs'; const url = 'http://x'; fs.readdirSync(__dirname);\n", + ); + fs.writeFileSync( + path.join(dir, 'export-assign.ts'), + "import fs from 'node:fs';\nexport = fs;\n", + ); + fs.writeFileSync( + path.join(dir, 'bracket.ts'), + "import fs from 'node:fs';\nexports['result'] = fs.statSync('.');\n", + ); + // The declaration covers only the function, not the module-level `require`. + fs.writeFileSync( + path.join(dir, 'inner.ts'), + [ + "import { createRequire } from 'node:module';", + 'function load() {', + ' const require = createRequire(import.meta.url);', + " return require('./x');", + '}', + "require('./setup');", + '', + ].join('\n'), + ); + fs.mkdirSync(path.join(dir, 'tests')); + fs.writeFileSync(path.join(dir, 'tests/ok.test.ts'), 'export {};\n'); + fs.writeFileSync( + path.join(dir, 'banner.ts'), + "/* banner */ import fs from 'node:fs'; fs.readdirSync(__dirname);\n", + ); + fs.writeFileSync( + path.join(dir, 'cli-guard.ts'), + "import fs from 'node:fs';\nif (require.main === module) fs.readdirSync('.');\n", + ); + fs.writeFileSync(path.join(dir, 'builtin.cts'), "const fs = require('node:fs');\n"); + fs.writeFileSync( + path.join(dir, 'module-filename.ts'), + "import fs from 'node:fs';\nconsole.log(module.filename);\n", + ); + fs.writeFileSync( + path.join(dir, 'node-module.ts'), + "import module from 'node:module';\nconst require = module.createRequire(import.meta.url);\nrequire('node:fs');\n", + ); + fs.writeFileSync( + path.join(dir, 'template-require.cts'), + 'const { x } = require(`./helper.js`);\n', + ); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it.each([ + ['tsx esm.cts', '`esm.cts` uses import or export'], + ['node --import tsx ./esm.cts', '`./esm.cts` uses import or export'], + ['tsx watch dirname.ts', '`dirname.ts` mixes import or export with CommonJS globals'], + [ + 'tsx --test plain.ts dirname.ts', + '`dirname.ts` mixes import or export with CommonJS globals', + ], + [ + 'concurrently "tsx dirname.ts"', + '`dirname.ts` mixes import or export with CommonJS globals', + ], + ['tsx exports.ts', '`exports.ts` mixes import or export with CommonJS globals'], + ['tsx compact.ts', '`compact.ts` mixes import or export with CommonJS globals'], + ['tsx commented.ts', '`commented.ts` mixes import or export with CommonJS globals'], + ['tsx bracket.ts', '`bracket.ts` mixes import or export with CommonJS globals'], + ['tsx string-url.ts', '`string-url.ts` mixes import or export with CommonJS globals'], + ['tsx export-assign.ts', '`export-assign.ts` mixes import or export with CommonJS globals'], + ['tsx banner.ts', '`banner.ts` mixes import or export with CommonJS globals'], + ['tsx cli-guard.ts', '`cli-guard.ts` mixes import or export with CommonJS globals'], + [ + 'tsx module-filename.ts', + '`module-filename.ts` mixes import or export with CommonJS globals', + ], + ['tsx template-require.cts', 'requires `./helper.js`, which only tsx resolves'], + [ + 'node --require tsx/cjs --require ./require-js.cts ./builtin.cts', + '`./require-js.cts` requires `./helper.js`', + ], + ['generate && tsx generated.ts', '`generated.ts` does not exist yet'], + ['tsx inner.ts', '`inner.ts` mixes import or export with CommonJS globals'], + ['tsx require-js.cts', 'requires `./helper.js`, which only tsx resolves to `./helper.ts`'], + ])('keeps %s', (command, reason) => { + expect(rewriteTsxCommand(command, dir)).toEqual({ + command, + kept: [expect.stringContaining(reason)], + }); + }); + + it('checks a preload given as an absolute path', () => { + // A root-level path is absolute on Windows too, unlike a temporary directory with + // an 8.3 name such as `RUNNER~1`, which reads as shell syntax. + const command = 'tsx --require /vp-missing-preload/setup.cts ./builtin.cts'; + expect(rewriteTsxCommand(command, dir)).toEqual({ + command, + kept: [expect.stringContaining('`/vp-missing-preload/setup.cts` does not exist yet')], + }); + }); + + it.each([ + ['tsx cjs.cts', 'vpx cjs.cts'], + ['tsx plain.ts', 'vpx plain.ts'], + ['tsx esm-idioms.ts', 'vpx esm-idioms.ts'], + ['tsx comment-only.ts', 'vpx comment-only.ts'], + ['tsx require-bare.cts', 'vpx require-bare.cts'], + ['tsx builtin.cts', 'vpx builtin.cts'], + ['tsx node-module.ts', 'vpx node-module.ts'], + ['tsx import-equals.cts', 'vpx import-equals.cts'], + ['tsx importer.ts', 'vpx importer.ts'], + ['tsx --test plain.ts', 'vpx --test plain.ts'], + // A glob, and a script after a shell state change, are not checked. + ['tsx --test tests/*.test.ts', 'vpx --test tests/*.test.ts'], + ['cd tools && tsx only.ts', 'cd tools && vpx only.ts'], + ])('rewrites %s', (command, expected) => { + expect(rewriteTsxCommand(command, dir)).toEqual({ command: expected, kept: [] }); + }); + + it('rewrites a script with __dirname in an ES module package', () => { + fs.writeFileSync(path.join(dir, 'package.json'), '{ "type": "module" }\n'); + // `"type": "module"` already ran it as an ES module under tsx. + expect(rewriteTsxCommand('tsx dirname.ts', dir).command).toBe('vpx dirname.ts'); + }); + + it('checks a script under the home directory', () => { + vi.spyOn(os, 'homedir').mockReturnValue(dir); + try { + expect(rewriteTsxCommand('tsx ~/dirname.ts', path.join(dir, 'tools')).kept).toEqual([ + expect.stringContaining('mixes import or export with CommonJS globals'), + ]); + } finally { + vi.restoreAllMocks(); + } + }); + }); +}); + +describe('migrateTsxToVpx', () => { + let root: string; + + const write = (file: string, content: string | object) => { + const target = path.join(root, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync( + target, + typeof content === 'string' ? content : `${JSON.stringify(content, null, 2)}\n`, + ); + }; + const read = (file: string) => fs.readFileSync(path.join(root, file), 'utf8'); + const readJson = (file: string) => JSON.parse(read(file)); + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'vp-test-tsx-')); + vi.spyOn(process, 'cwd').mockReturnValue(root); + // The scripts the commands below run; migration reads them before rewriting. + for (const file of [ + 'src/server.ts', + 'scripts/seed.ts', + 'scripts/check.ts', + 'scripts/docs.ts', + 'packages/a/gen.ts', + ]) { + write(file, 'export {};\n'); + } + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('rewrites scripts, lint-staged tasks, and shebangs, then removes tsx', () => { + write('package.json', { + type: 'module', + scripts: { dev: 'tsx watch src/server.ts', seed: 'npx tsx scripts/seed.ts' }, + 'lint-staged': { '*.ts': ['tsx scripts/check.ts'] }, + // A file extension, not a use of tsx. + jest: { moduleFileExtensions: ['ts', 'tsx'] }, + devDependencies: { tsx: '^4.20.0', vite: '^7.0.0' }, + }); + write('.lintstagedrc.json', { '*.md': 'tsx scripts/docs.ts' }); + write('scripts/release.ts', '#!/usr/bin/env tsx\nconsole.log("release");\n'); + write('src/server.ts', 'export {};\n'); + // File extensions are not uses of tsx. + write('vite.config.ts', "export default { resolve: { extensions: ['ts', 'tsx'] } };\n"); + write('src/App.vue', '\n'); + write('.eslintrc.json', { settings: { 'import/resolver': { node: { extensions: ['tsx'] } } } }); + // Caches and reports are not scanned. + write('.next/cache/config.json', { command: 'tsx scripts/old.ts' }); + write('coverage/report.json', { command: 'tsx scripts/old.ts' }); + const report = createMigrationReport(); + + expect(detectTsxProject(root)).toBe(true); + expect(migrateTsxToVpx(root, undefined, report)).toEqual({ + changed: true, + dependencyRemoved: true, + }); + + expect(readJson('package.json')).toEqual({ + type: 'module', + scripts: { dev: 'vpx --watch src/server.ts', seed: 'vpx scripts/seed.ts' }, + 'lint-staged': { '*.ts': ['vpx scripts/check.ts'] }, + jest: { moduleFileExtensions: ['ts', 'tsx'] }, + devDependencies: { vite: '^7.0.0' }, + }); + expect(readJson('.lintstagedrc.json')).toEqual({ '*.md': 'vpx scripts/docs.ts' }); + expect(read('scripts/release.ts')).toBe('#!/usr/bin/env vpx\nconsole.log("release");\n'); + expect(report.tsxMigrated).toBe(true); + expect(report.warnings).toEqual([]); + + // A second run has nothing left to do. + expect(detectTsxProject(root)).toBe(false); + expect(migrateTsxToVpx(root, undefined, createMigrationReport())).toEqual({ + changed: false, + dependencyRemoved: false, + }); + }); + + it.each([ + ['a kept script', 'package.json', undefined, '`scripts` in package.json still uses tsx'], + [ + 'an import of the tsx API', + 'src/load.ts', + "import { tsImport } from 'tsx/esm/api';\n", + 'src/load.ts still uses tsx', + ], + [ + 'a template-literal import of the tsx API', + 'src/lazy.ts', + 'const { tsImport } = await import(`tsx/esm/api`);\n', + 'src/lazy.ts still uses tsx', + ], + [ + 'an annotated dynamic import of the tsx API', + 'src/ignored.ts', + "const api = await import(/* @vite-ignore */ 'tsx/esm/api');\n", + 'src/ignored.ts still uses tsx', + ], + [ + 'a resolved tsx subpath', + 'src/register.ts', + "const loader = import.meta.resolve('tsx/esm');\n", + 'src/register.ts still uses tsx', + ], + [ + 'a Mocha YAML config', + '.mocharc.yml', + 'node-option:\n - import=tsx\n', + '.mocharc.yml still uses tsx', + ], + [ + 'a Mocha JavaScript config', + '.mocharc.cjs', + "module.exports = { 'node-option': ['import=tsx'] };\n", + '.mocharc.cjs still uses tsx', + ], + [ + 'a CI workflow', + '.github/workflows/ci.yml', + 'jobs:\n run: npx tsx scripts/ci.ts\n', + '.github/workflows/ci.yml still uses tsx', + ], + [ + 'a git hook', + '.husky/pre-commit', + 'npx tsx scripts/check.ts\n', + '.husky/pre-commit still uses tsx', + ], + [ + 'a bash script', + 'tools/release.bash', + 'tsx scripts/release.ts\n', + 'tools/release.bash still uses tsx', + ], + [ + 'an extensionless shebang file', + 'bin/release', + '#!/usr/bin/env tsx\nexport {};\n', + 'bin/release still uses tsx', + ], + [ + 'an extensionless JavaScript file', + 'bin/cli', + "import { tsImport } from 'tsx/esm/api';\n", + 'bin/cli still uses tsx', + ], + [ + 'a nested package', + 'examples/demo/package.json', + '{ "scripts": { "start": "tsx index.ts" } }\n', + '`scripts` in examples/demo/package.json still uses tsx', + ], + [ + 'build output', + 'dist/launcher.js', + "import { register } from 'tsx/esm/api';\n", + 'dist/launcher.js still uses tsx', + ], + [ + 'a process call', + 'src/run.ts', + "import { spawn } from 'node:child_process';\nspawn('tsx', ['job.ts']);\n", + 'src/run.ts still uses tsx', + ], + [ + 'a process call without arguments', + 'src/repl.ts', + "import { spawn } from 'node:child_process';\nspawn('tsx');\n", + 'src/repl.ts still uses tsx', + ], + [ + 'a process call with an argv array', + 'src/job.ts', + "Bun.spawn(['tsx', 'job.ts']);\n", + 'src/job.ts still uses tsx', + ], + [ + 'a TOML config', + 'netlify.toml', + '[build]\ncommand = "tsx scripts/build.ts"\n', + 'netlify.toml still uses tsx', + ], + [ + 'a shell pipe into tsx', + 'scripts/run.sh', + 'cat job.ts | tsx\n', + 'scripts/run.sh still uses tsx', + ], + [ + 'a quoted tsx in a shell script', + 'scripts/job.sh', + '"tsx" scripts/job.ts\n', + 'scripts/job.sh still uses tsx', + ], + [ + 'a Windows batch file', + 'scripts/run.cmd', + '@node_modules\\.bin\\tsx.cmd scripts\\a.ts\r\n', + 'scripts/run.cmd still uses tsx', + ], + [ + 'a JSON tool config', + '.mocharc.json', + '{ "require": "tsx/cjs", "spec": "test/**/*.test.ts" }\n', + '.mocharc.json still uses tsx', + ], + [ + 'a bare loader in a JSON tool config', + '.mocharc.json', + '{ "import": "tsx" }\n', + '.mocharc.json still uses tsx', + ], + [ + 'a bare loader list in a JSON tool config', + '.mocharc.json', + '{ "require": ["tsx"] }\n', + '.mocharc.json still uses tsx', + ], + [ + 'a bare lint-staged task', + '.lintstagedrc.json', + '{ "*.ts": "tsx" }\n', + '.lintstagedrc.json still uses tsx', + ], + [ + 'a bare lint-staged task in an extensionless config', + '.lintstagedrc', + '{"*.ts":"tsx"}\n', + '.lintstagedrc still uses tsx', + ], + [ + 'a JSONC tool config', + 'wrangler.jsonc', + '{\n // Build step\n "build": { "command": "tsx scripts/build.ts" },\n}\n', + 'wrangler.jsonc still uses tsx', + ], + [ + 'a JavaScript tool config', + 'ava.config.js', + "export default { nodeArguments: ['--import=tsx'] };\n", + 'ava.config.js still uses tsx', + ], + [ + 'a JavaScript tool config with split loader arguments', + 'ava.config.mjs', + "export default { nodeArguments: ['--import', 'tsx'] };\n", + 'ava.config.mjs still uses tsx', + ], + [ + 'a framework component', + 'src/pages/index.astro', + "---\nimport { tsImport } from 'tsx/esm/api';\n---\n", + 'src/pages/index.astro still uses tsx', + ], + ])('keeps the tsx dependency used by %s', (_, file, content, user) => { + write('package.json', { + scripts: { + dev: 'tsx watch src/server.ts', + ...(content === undefined ? { repl: 'tsx' } : {}), + }, + devDependencies: { tsx: '^4.20.0' }, + }); + if (content !== undefined) { + write(file, content); + } + const report = createMigrationReport(); + + expect(migrateTsxToVpx(root, undefined, report).dependencyRemoved).toBe(false); + expect(readJson('package.json').scripts.dev).toBe('vpx --watch src/server.ts'); + expect(readJson('package.json').devDependencies).toEqual({ tsx: '^4.20.0' }); + expect(report.warnings).toContain(`Kept the tsx devDependency in package.json: ${user}.`); + }); + + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])( + 'keeps tsx in a package that lists it in %s', + (field) => { + write('package.json', { + scripts: { start: 'tsx src/server.ts' }, + [field]: { tsx: '^4.20.0' }, + }); + const report = createMigrationReport(); + + expect(detectTsxProject(root)).toBe(false); + expect(migrateTsxToVpx(root, undefined, report).changed).toBe(false); + expect(readJson('package.json').scripts.start).toBe('tsx src/server.ts'); + expect(report.warnings).toEqual([expect.stringContaining('Kept tsx in package.json')]); + }, + ); + + it('keeps the tsx dependency of a workspace package used by its nested example', () => { + write('package.json', { private: true }); + write('packages/a/package.json', { + scripts: { gen: 'tsx gen.ts' }, + devDependencies: { tsx: '^4.20.0' }, + }); + write('packages/a/examples/demo/main.ts', "import { tsImport } from 'tsx/esm/api';\n"); + write('packages/a/examples/demo/package.json', { private: true }); + const report = createMigrationReport(); + + migrateTsxToVpx(root, [{ name: 'a', path: 'packages/a' }], report); + + expect(readJson('packages/a/package.json')).toEqual({ + scripts: { gen: 'vpx gen.ts' }, + devDependencies: { tsx: '^4.20.0' }, + }); + expect(report.warnings).toEqual([ + 'Kept the tsx devDependency in packages/a/package.json: packages/a/examples/demo/main.ts still uses tsx.', + ]); + }); + + it.each([ + ['imports', { '#loader': { node: 'tsx/esm/api' } }], + ['imports', { '#loader': 'tsx' }], + ['ava', { extensions: { ts: 'module' }, nodeArguments: ['--import=tsx'] }], + ])('keeps the tsx dependency used by the %s field of package.json', (field, value) => { + write('package.json', { + scripts: { dev: 'tsx watch src/server.ts' }, + [field]: value, + devDependencies: { tsx: '^4.20.0' }, + }); + const report = createMigrationReport(); + + expect(migrateTsxToVpx(root, undefined, report).dependencyRemoved).toBe(false); + expect(readJson('package.json').scripts.dev).toBe('vpx --watch src/server.ts'); + expect(report.warnings).toEqual([ + `Kept the tsx devDependency in package.json: \`${field}\` in package.json still uses tsx.`, + ]); + }); + + it('detects a project that runs tsx only through shebangs', () => { + write('package.json', { + type: 'module', + scripts: { release: './scripts/release.ts' }, + devDependencies: { tsx: '^4.20.0' }, + }); + write('scripts/release.ts', '#!/usr/bin/env tsx\nexport {};\n'); + + expect(detectTsxProject(root)).toBe(true); + expect(migrateTsxToVpx(root)).toEqual({ changed: true, dependencyRemoved: true }); + expect(read('scripts/release.ts')).toBe('#!/usr/bin/env vpx\nexport {};\n'); + expect(detectTsxProject(root)).toBe(false); + }); + + it('detects tsx tasks in a lint-staged JSON file', () => { + write('package.json', { scripts: { prepare: 'husky' } }); + write('.lintstagedrc.json', { '*.ts': 'tsx scripts/check.ts' }); + + expect(detectTsxProject(root)).toBe(true); + migrateTsxToVpx(root); + expect(readJson('.lintstagedrc.json')).toEqual({ '*.ts': 'vpx scripts/check.ts' }); + expect(detectTsxProject(root)).toBe(false); + }); + + it('rewrites shebang options as it rewrites commands', () => { + write('package.json', { type: 'module' }); + write('scripts/a.ts', '#!/usr/bin/env -S tsx --no-cache --tsconfig cli.json\nexport {};\n'); + write('scripts/b.ts', '#!/usr/bin/env -S tsx -e x\nexport {};\n'); + const report = createMigrationReport(); + + migrateTsxToVpx(root, undefined, report); + + expect(read('scripts/a.ts')).toBe('#!/usr/bin/env -S vpx --tsconfig cli.json\nexport {};\n'); + expect(read('scripts/b.ts')).toBe('#!/usr/bin/env -S tsx -e x\nexport {};\n'); + expect(report.warnings).toEqual([ + expect.stringContaining('Kept tsx in the shebang of scripts/b.ts: `vpx` does not run'), + ]); + }); + + it('keeps tsx for a script that would lose CommonJS globals under vpx', () => { + write('package.json', { scripts: { build: 'tsx scripts/build.ts' } }); + write('scripts/build.ts', "import fs from 'node:fs';\nfs.readdirSync(__dirname);\n"); + write( + 'scripts/release.ts', + '#!/usr/bin/env tsx\nimport fs from "node:fs";\nrequire("node:path");\n', + ); + const report = createMigrationReport(); + + expect(detectTsxProject(root)).toBe(false); + migrateTsxToVpx(root, undefined, report); + + expect(readJson('package.json').scripts.build).toBe('tsx scripts/build.ts'); + expect(read('scripts/release.ts')).toMatch(/^#!\/usr\/bin\/env tsx\n/); + expect(report.warnings).toEqual([ + expect.stringContaining( + 'Kept tsx in package.json scripts "build": `scripts/build.ts` mixes import or export with CommonJS globals', + ), + expect.stringContaining( + 'Kept tsx in the shebang of scripts/release.ts: `./release.ts` mixes import or export with CommonJS globals', + ), + ]); + }); + + it('keeps the root tsx while a workspace package still uses it', () => { + write('package.json', { private: true, devDependencies: { tsx: '^4.20.0' } }); + write('packages/a/package.json', { scripts: { gen: 'tsx gen.ts' } }); + write('packages/b/package.json', { scripts: { calc: "tsx -e 'run()'" } }); + const packages = [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ]; + const report = createMigrationReport(); + + migrateTsxToVpx(root, packages, report); + + expect(readJson('packages/a/package.json').scripts.gen).toBe('vpx gen.ts'); + expect(readJson('packages/b/package.json').scripts.calc).toBe("tsx -e 'run()'"); + expect(readJson('package.json').devDependencies).toEqual({ tsx: '^4.20.0' }); + expect(report.warnings).toEqual([ + expect.stringContaining('Kept tsx in packages/b/package.json scripts "calc"'), + 'Kept the tsx devDependency in package.json: `scripts` in packages/b/package.json still uses tsx.', + ]); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index d374bb2b52..29cc62b1a2 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -49,12 +49,14 @@ import { checkViteVersion, collectToolchainVersionChanges, confirmPrettierMigration, + confirmTsxMigration, detectEslintProject, detectFramework, detectNodeVersionManagerFile, detectPendingCoreMigration, detectPrettierProject, detectTsupProject, + detectTsxProject, detectVitePlusBootstrapPending, detectYarnPnpMode, ensureVitePlusBootstrap, @@ -72,6 +74,7 @@ import { migratePrettierToOxfmt, migrateSetupVpVersion, migrateTsupToTsdown, + migrateTsxToVpx, configureYarnNodeModulesMode, rewriteMonorepo, rewriteStandaloneProject, @@ -281,6 +284,7 @@ interface MigrationPlan extends MigrationSetupPlan { prettierConfigFile?: string; migrateTsup: boolean; tsupConfigFile?: string; + migrateTsx: boolean; fixBaseUrl: boolean; migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; @@ -377,6 +381,7 @@ function hasExistingVitePlusMigrationCandidates( eslintProject.hasDependency || prettierProject.hasDependency || tsupProject.hasDependency || + detectTsxProject(workspaceInfo.rootDir, workspaceInfo.packages) || detectNodeVersionManagerFile(workspaceInfo.rootDir) !== undefined || getFrameworkShimCandidates(workspaceInfo.rootDir, workspaceInfo.packages).length > 0 ); @@ -436,6 +441,10 @@ async function collectMigrationPlan( packages, ); + // 3c. tsx detection + prompt + const migrateTsx = + detectTsxProject(rootDir, packages) && (await confirmTsxMigration(options.interactive)); + // 9. tsconfig baseUrl prompt const fixBaseUrl = hasBaseUrlInWorkspace({ rootDir, packages }) ? await confirmBaseUrlFix(options.interactive) @@ -463,6 +472,7 @@ async function collectMigrationPlan( prettierConfigFile: prettierProject.configFile, migrateTsup, tsupConfigFile, + migrateTsx, fixBaseUrl, migrateNodeVersionFile, nodeVersionDetection, @@ -571,6 +581,9 @@ function showMigrationSummary(options: { if (report.tsupMigrated) { log(`${styleText('gray', '•')} tsup config migrated to tsdown (\`vp pack\`)`); } + if (report.tsxMigrated) { + log(`${styleText('gray', '•')} tsx scripts migrated to \`vpx\``); + } if (report.nodeVersionFileMigrated) { log(`${styleText('gray', '•')} Node version manager file migrated to .node-version`); } @@ -846,6 +859,12 @@ async function executeMigrationPlan( } } + // 6c. tsx → vpx (before main rewrite so rewritten lint-staged tasks get merged) + if (plan.migrateTsx) { + updateMigrationProgress('Migrating tsx scripts'); + migrateTsxToVpx(workspaceInfo.rootDir, workspaceInfo.packages, report); + } + // Earlier setup and tool migrations can abort or change the preflight inputs. // Only now apply Vitest compatibility edits, using the original runner version. vitestV5Plan = applyRefreshedVitestV5Migration(vitestV5Plan); @@ -1373,6 +1392,27 @@ async function main() { } } + if ( + fullSetup && + detectTsxProject(workspaceInfoOptional.rootDir, workspaceInfoOptional.packages) + ) { + // Interactive only: stop any active spinner so it does not animate beneath + // the confirm prompt. + if (options.interactive) { + clearMigrationProgress(); + } + if (await confirmTsxMigration(options.interactive)) { + updateMigrationProgress('Migrating tsx scripts'); + const tsxResult = migrateTsxToVpx( + workspaceInfoOptional.rootDir, + workspaceInfoOptional.packages, + report, + ); + didMigrate ||= tsxResult.changed; + needsInstall ||= tsxResult.dependencyRemoved; + } + } + // Check if node version manager file migration is needed (full setup only) if (fullSetup) { const nodeVersionDetection = detectNodeVersionManagerFile(workspaceInfoOptional.rootDir); diff --git a/packages/cli/src/migration/migrator.ts b/packages/cli/src/migration/migrator.ts index 4433d1d157..c31f63449d 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -2,6 +2,7 @@ export * from './migrator/shared.ts'; export * from './migrator/eslint.ts'; export * from './migrator/prettier.ts'; export * from './migrator/tsup.ts'; +export * from './migrator/tsx.ts'; export * from './migrator/tsconfig.ts'; export * from './migrator/framework-shim.ts'; export * from './migrator/vitest-ecosystem.ts'; diff --git a/packages/cli/src/migration/migrator/README.md b/packages/cli/src/migration/migrator/README.md index b93087cfde..e1c9624115 100644 --- a/packages/cli/src/migration/migrator/README.md +++ b/packages/cli/src/migration/migrator/README.md @@ -34,6 +34,7 @@ Pick the file by what a function _does_, not by where it happens to be called. | `eslint.ts` | ESLint → Oxlint migration, oxlint JS-plugin namespace handling, ESLint prompts/warnings. | | `prettier.ts` | Prettier → Oxfmt migration and its prompts/warnings. | | `tsup.ts` | tsup → tsdown (`vp pack`) migration via `tsdown-migrate` and its prompts/warnings. | +| `tsx.ts` | tsx → `vpx` script migration, tsx dependency retention, and its prompt/warnings. | | `tsconfig.ts` | `tsconfig.json` cleanup and `types` rewriting. | | `framework-shim.ts` | Framework (Vue/Astro) shim detection and injection. | | `git-hooks.ts` | Vite+ hook defaults, project-owned hook preservation, and conservative detection of existing hook tools. | diff --git a/packages/cli/src/migration/migrator/shared.ts b/packages/cli/src/migration/migrator/shared.ts index ecff286023..03473942b6 100644 --- a/packages/cli/src/migration/migrator/shared.ts +++ b/packages/cli/src/migration/migrator/shared.ts @@ -30,7 +30,11 @@ export function getCommandStartIndex(argv: readonly string[]): number { return 0; } let start = 1; - if (argv[start] === 'exec' || argv[start] === 'x') { + if (argv[start] === 'exec' || argv[start] === 'x' || argv[start] === 'dlx') { + start++; + } + // `npx --yes tsx`: skip the install prompt flag. + if (argv[start] === '-y' || argv[start] === '--yes') { start++; } if (argv[start] === '--') { @@ -39,6 +43,15 @@ export function getCommandStartIndex(argv: readonly string[]): number { return start; } +// Scans that decide whether a removed tool is still used. Built output can still load +// it after migration, so skip only installed source and VCS metadata. +export const RETENTION_SCAN_SKIP_DIRS: ReadonlySet = new Set([ + 'node_modules', + '.git', + '.hg', + '.svn', +]); + // Oxlint does not inherit these fields through `extends`. export const OXLINT_OWNER_CONFIG_KEYS = new Set(['env', 'globals', 'settings', 'ignorePatterns']); diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 6d01d0b872..2b2f9f933b 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -13,6 +13,7 @@ import { packageOwnsOxlintApi, PLAYWRIGHT_PROVIDER, readPackageJsonIfExists, + RETENTION_SCAN_SKIP_DIRS, type DependencyBag, } from './shared.ts'; @@ -168,10 +169,6 @@ const VITEST_SCAN_SKIP_DIRS = new Set([ '.yarn', ]); -// Built plugins can still load the original API after migration. Skip installed -// source and VCS metadata; dependency peer contracts are checked separately. -const OXLINT_RETENTION_SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn']); - /** * Detect whether a package uses vitest's browser mode. * @@ -196,13 +193,14 @@ const OXLINT_RETENTION_SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn * is a separate package that the migration scans on its own pass, so the root * package must not inherit a browser-mode signal from a sub-package. */ -function sourceTreeMatches( +export function sourceTreeMatches( projectPath: string, - matchesContent: (content: string) => boolean, + matchesContent: (content: string, file: string) => boolean, options: { // Nested examples can resolve the root's dependency through source or // package imports/scripts, even when they are not workspace members. crossPackageBoundaries?: boolean; + extensions?: ReadonlySet; includePackageReferences?: boolean; includeExtensionless?: boolean; matchesPackage?: (projectPath: string, pkg: DependencyBag) => boolean; @@ -210,6 +208,7 @@ function sourceTreeMatches( } = {}, ): boolean { const skipDirs = options.skipDirs ?? VITEST_SCAN_SKIP_DIRS; + const extensions = options.extensions ?? VITEST_SCAN_EXTENSIONS; function scanDir(dir: string, isRoot: boolean): boolean { let entries: fs.Dirent[]; try { @@ -238,7 +237,7 @@ function sourceTreeMatches( } else if ( entry.isFile() && !['.pnp.cjs', '.pnp.loader.mjs'].includes(entry.name) && - (VITEST_SCAN_EXTENSIONS.has(path.extname(entry.name)) || + (extensions.has(path.extname(entry.name)) || (options.includeExtensionless && path.extname(entry.name) === '') || (options.includePackageReferences && entry.name === 'package.json')) ) { @@ -256,7 +255,7 @@ function sourceTreeMatches( } content = JSON.stringify({ imports: pkg.imports, scripts: pkg.scripts }); } - if (matchesContent(content)) { + if (matchesContent(content, entryPath)) { return true; } } catch { @@ -377,7 +376,7 @@ export function sourceTreeReferencesOxlintPluginsPackage( retainUnknownPeers: originalDependencies.has(dir), originalDependencyNames: originalDependencies.get(dir), }), - skipDirs: OXLINT_RETENTION_SKIP_DIRS, + skipDirs: RETENTION_SCAN_SKIP_DIRS, }); } diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts new file mode 100644 index 0000000000..67f1865830 --- /dev/null +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -0,0 +1,961 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { styleText } from 'node:util'; + +import * as prompts from '@voidzero-dev/vite-plus-prompts'; +import { parse as parseJsonc } from 'jsonc-parser'; + +import { type LiteralCommandToken, parseLiteralCommands } from '../../../binding/index.js'; +import { type WorkspacePackage } from '../../types/index.ts'; +import { editJsonFile, isJsonFile, readJsonFile } from '../../utils/json.ts'; +import { readNearestPackageJson } from '../../utils/package.ts'; +import { displayRelative } from '../../utils/path.ts'; +import { cancelAndExit } from '../../utils/prompts.ts'; +import { + NODE_OPTIONS_WITH_VALUE, + SCRIPT_EXTENSIONS, + WATCH_OPTIONS_WITH_VALUE, +} from '../../vpx-script.ts'; +import { sourceTreeMatches } from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; +import { + type DependencyBag, + getCommandStartIndex, + isPlainRecord, + LINT_STAGED_JSON_CONFIG_FILES, + readPackageJsonIfExists, + RETENTION_SCAN_SKIP_DIRS, + SHELL_STATE_COMMANDS, + warnMigration, +} from './shared.ts'; + +// tsx in command position: `tsx a.ts`, `npx tsx`, `--import tsx`, `--import=tsx`, +// `-r tsx/cjs`, `node_modules\.bin\tsx.cmd`, or inside a quoted command, in any case, as +// Windows finds `TSX`. Not `.tsx` files or packages such as `tsx-foo`. +const TSX_COMMAND = + /(?:^|[\s;&|("'=/\\`])tsx(?:\.(?:cmd|exe|ps1))?(?:\/(?:esm|cjs))?(?=$|[\s;&|)"'`])/i; + +// Node.js options that load tsx's hooks, as in `node --import tsx ./a.ts`. +const LOADER_OPTIONS = new Set([ + '--import', + '--loader', + '--experimental-loader', + '--require', + '-r', +]); +const TSX_LOADER = /^tsx(?:\/(?:esm|cjs))?$/; +// Tool settings that name modules to preload, as in Mocha's `"import": "tsx"`. +const LOADER_SETTINGS = new Set(['import', 'loader', 'require']); + +const KEEP_EVAL = '`vpx` does not run TypeScript passed to --eval or --print'; +const KEEP_CHECK = '`vpx` does not transform TypeScript for -c or --check'; +const KEEP_REPL = '`vpx` does not start a TypeScript REPL'; +const KEEP_TEST_GLOBS = + '`--test` without files finds `.ts` tests with tsx globs; list the test files for `vpx --test`'; +const KEEP_WATCH_PATH = '`--watch-path` has no `vpx` equivalent; use `--include`'; +const KEEP_SHELL = + 'the command uses shell syntax that vp migrate does not rewrite, such as pipes, variables, or subshells'; +const KEEP_OTHER = 'vp migrate does not rewrite this use of tsx'; +const KEEP_TSX_TSCONFIG = + '`TSX_TSCONFIG_PATH` selects the tsconfig for tsx only; pass it to `vpx` as `--tsconfig`'; +const KEEP_NODE_OPTIONS = + '`NODE_OPTIONS` loads tsx, whose hooks would compete with those of `vpx`; remove it first'; +const KEEP_WATCH_TEST = '`vpx --watch` does not run `--test`'; + +// Options that `vpx` does not run as tsx does. `-c` is Node.js' `--check`, but `vpx -c` +// is `--shell-mode`. +const KEEP_OPTIONS = new Map([ + ['-e', KEEP_EVAL], + ['--eval', KEEP_EVAL], + ['-p', KEEP_EVAL], + ['--print', KEEP_EVAL], + ['-pe', KEEP_EVAL], + ['-c', KEEP_CHECK], + ['--check', KEEP_CHECK], + ['-i', KEEP_REPL], + ['--interactive', KEEP_REPL], + ['--watch-path', KEEP_WATCH_PATH], +]); + +// A tsx shebang `vpx` can take over: `#!/usr/bin/env tsx`, `#!/usr/bin/env -S tsx --tsconfig x.json`. +const TSX_ENV_SHEBANG = /^(#!\s*\S*\benv\s+(?:-S\s+)?)(tsx(?=[ \t\r\n]|$)[^\r\n]*)/; +// Stands for the file that the kernel appends to a shebang's command. +const SHEBANG_ENTRY = './shebang.ts'; +// Any first line that runs tsx, including `#!/usr/local/bin/tsx`. +const TSX_SHEBANG = /^#![^\n]*[\s/]tsx(?=\s|$)/; +// tsx loaded into Node.js by a flag, as in `--import=tsx` or `-r tsx/cjs`, or by Mocha's +// dashless `node-option` value `import=tsx`. +const TSX_LOADER_FLAG = + /(?:(?:--import|--loader|--experimental-loader|--require|-r)[= \t]+|(? = new Set([ + ...SCRIPT_EXTENSIONS, + '.astro', + '.svelte', + '.vue', +]); +const REFERENCE_EXTENSIONS: ReadonlySet = new Set([ + ...CODE_EXTENSIONS, + '.json', + '.jsonc', + '.toml', + '.yml', + '.yaml', + '.sh', + '.bash', + '.zsh', + '.fish', + '.ps1', + '.cmd', + '.bat', +]); +// Lockfiles name tsx without running it. +const LOCKFILES = new Set([ + 'bun.lock', + 'npm-shrinkwrap.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +]); +// The scan for remaining tsx uses reads build output, which can still load tsx, but not +// caches and reports. +const USE_SCAN_SKIP_DIRS: ReadonlySet = new Set([ + ...RETENTION_SCAN_SKIP_DIRS, + '.cache', + '.next', + '.nuxt', + '.svelte-kit', + '.turbo', + '.vite', + 'coverage', +]); + +// ES module syntax at the start of a statement, including compact forms such as +// `export{}` and `export*from`, and uses of the CommonJS globals an ES module does not +// have. TypeScript's `import x = require()` and `export =` stay CommonJS. +const ESM_SYNTAX = + /(?:^|;)\s*(?:import(?![\w$])\s*(?![\w$]+\s*=)[\w*{'"]|export(?![\w$])\s*[\w*{])|\bimport\.meta\b/m; +const COMMONJS_GLOBALS = [ + ['__dirname', /(? = { + js: ['ts', 'tsx'], + cjs: ['cts'], + mjs: ['mts'], + jsx: ['tsx'], +}; +// Programs that run commands given as quoted arguments, and which arguments those are, +// judged by the argument before: every one for `concurrently "tsx watch a.ts" "vp dev"`, +// only the value of `-c` for a shell. Elsewhere, a quoted string is data, as for `echo`. +const shellCommand = (previous: string) => /^-[a-z]*c$/.test(previous); +const COMMAND_LAUNCHERS = new Map boolean>([ + ['bash', shellCommand], + ['concurrently', () => true], + ['nodemon', (previous) => previous === '--exec' || previous === '-x'], + ['sh', shellCommand], + ['zsh', shellCommand], +]); + +export interface TsxCommandRewrite { + command: string; + /** Why tsx stays in part of the command. */ + kept: string[]; +} + +interface Edit { + start: number; + end: number; + text: string; +} + +interface Rewrite { + command: string; + /** The directory the next segment runs in, or undefined when it is not known. */ + cwd?: string; + edits: Edit[]; + kept: string[]; +} + +/** + * Rewrite the tsx invocations in a command to `vpx`: `tsx a.ts` becomes `vpx a.ts`, + * `tsx watch a.ts` becomes `vpx --watch a.ts` (`vpx watch` runs the npm package `watch`), + * and `node --import tsx a.ts` becomes `vpx a.ts`. Package-manager wrappers such as + * `npx tsx` go away with tsx, since `vpx` is a bin of vite-plus. A form `vpx` cannot run + * stays as it is, with the reason in `kept`. With `cwd`, the directory the command runs + * in, the scripts it runs are checked for syntax `vpx` runs differently. + */ +export function rewriteTsxCommand(command: string, cwd?: string): TsxCommandRewrite { + if (!TSX_COMMAND.test(command)) { + return { command, kept: [] }; + } + const segments = parseLiteralCommands(command, true); + // The parser rejects `$VAR`; cmd.exe expands `%VAR%` too. + if (!segments || /%\w+%/.test(command)) { + return { command, kept: [KEEP_SHELL] }; + } + const rewrite: Rewrite = { command, cwd, edits: [], kept: [] }; + for (const tokens of segments) { + const name = tokens[0].value; + // After `cd` or another shell state change, scripts are no longer checked. + if (SHELL_STATE_COMMANDS.includes(name) || name === 'chdir') { + rewrite.cwd = undefined; + continue; + } + rewriteSegment(rewrite, tokens); + } + let rewritten = command; + for (const edit of rewrite.edits.toSorted((a, b) => b.start - a.start)) { + rewritten = rewritten.slice(0, edit.start) + edit.text + rewritten.slice(edit.end); + } + if (!rewrite.kept.length && TSX_COMMAND.test(rewritten)) { + rewrite.kept.push(KEEP_OTHER); + } + return { command: rewritten, kept: rewrite.kept }; +} + +function rewriteSegment(rewrite: Rewrite, tokens: LiteralCommandToken[]): void { + const values = tokens.map(({ value }) => value); + // Environment assignments and cross-env stay in front of the command. + let first = 0; + const skipAssignments = () => { + while (/^[A-Za-z_]\w*=/.test(values[first] ?? '')) { + first++; + } + }; + skipAssignments(); + // cross-env-shell runs its command, given as one quoted argument, in a shell. + const shell = values[first] === 'cross-env-shell'; + if (values[first] === 'cross-env' || shell) { + first++; + skipAssignments(); + } + if (first >= values.length) { + return; + } + // tsx reads TSX_TSCONFIG_PATH, where `vpx` takes `--tsconfig`, and tsx hooks loaded + // through NODE_OPTIONS would compete with those of `vpx`. Both reach the commands a + // launcher runs. + const envReason = envKeepReason(values.slice(0, first)); + const start = first + getCommandStartIndex(values.slice(first)); + const launcher = COMMAND_LAUNCHERS.get(values[start]); + if (values[start] === 'tsx') { + rewriteTsx(rewrite, tokens, first, start, envReason); + } else if (values[start] === 'node') { + rewriteNodeLoader(rewrite, tokens, first, start, envReason); + } else if (shell) { + for (const token of tokens.slice(start)) { + rewriteQuotedCommand(rewrite, token, envReason); + } + } else if (launcher) { + for (let index = start + 1; index < tokens.length; index++) { + if (launcher(values[index - 1])) { + rewriteQuotedCommand(rewrite, tokens[index], envReason); + } + } + } +} + +/** + * Why environment assignments keep tsx for the command they apply to, or undefined. + * Names match in any case, as on Windows. + */ +function envKeepReason(assignments: readonly string[]): string | undefined { + if (assignments.some((value) => /^TSX_TSCONFIG_PATH=/i.test(value))) { + return KEEP_TSX_TSCONFIG; + } + if ( + assignments.some((value) => /^NODE_OPTIONS=/i.test(value) && TSX_COMMAND.test(value.slice(13))) + ) { + return KEEP_NODE_OPTIONS; + } + return undefined; +} + +/** + * An option's name without `=value`. Node.js reads `_` as `-` in long option names, as + * in `--watch_path`, and so does `vpx`. + */ +function optionName(arg: string): string { + const name = arg.split('=', 1)[0]; + return name.startsWith('--') ? `--${name.slice(2).replaceAll('_', '-')}` : name; +} + +/** Remove `tokens[index]` with the space before it. */ +function removal(tokens: LiteralCommandToken[], index: number): Edit { + return { start: tokens[index - 1].end, end: tokens[index].end, text: '' }; +} + +/** + * Why `vpx` would fail on a script that tsx runs, judged from its source. tsx resolves + * `require('./a.js')` to `a.ts`, and compiles ES module syntax to CommonJS in a `.cts` + * file and in a package without `"type": "module"`. `vpx` resolves `require()` as + * Node.js does, rejects ES module syntax in a `.cts` file, and runs the other files as ES + * modules, so `__dirname`, `__filename`, and `require` are not defined. + */ +function scriptKeepReason(cwd: string | undefined, script: string): string | undefined { + // A glob is not checked: its files can differ when the command runs. + if ( + cwd === undefined || + /[*?[{]/.test(script) || + !SCRIPT_EXTENSIONS.some((extension) => script.endsWith(extension)) + ) { + return undefined; + } + const file = script.startsWith('~/') + ? path.join(os.homedir(), script.slice(2)) + : path.resolve(cwd, script); + let source: string; + try { + source = fs.readFileSync(file, 'utf8'); + } catch (error) { + // A file that an earlier command generates cannot be checked yet. + return (error as NodeJS.ErrnoException).code === 'ENOENT' + ? `\`${script}\` does not exist yet, so vp migrate cannot check it` + : undefined; + } + for (const [, base, extension] of source.matchAll(RELATIVE_REQUIRE)) { + const resolve = (ext: string) => path.resolve(path.dirname(file), `${base}.${ext}`); + const typescript = fs.existsSync(resolve(extension)) + ? undefined + : TYPESCRIPT_EXTENSIONS[extension].find((ext) => fs.existsSync(resolve(ext))); + if (typescript) { + return `\`${script}\` requires \`${base}.${extension}\`, which only tsx resolves to \`${base}.${typescript}\`; write the TypeScript extension`; + } + } + // A comment can start a line before ES module syntax, as in `/* banner */ import ...`. + const code = stripComments(source); + if (!/\.(?:c?ts|tsx|jsx?)$/.test(script) || !(ESM_SYNTAX.test(source) || ESM_SYNTAX.test(code))) { + return undefined; + } + if (script.endsWith('.cts')) { + return `\`${script}\` uses import or export, which \`vpx\` does not compile to CommonJS; rename it to .mts`; + } + if ( + (usesCommonJsGlobals(code) || EXPORT_ASSIGNMENT.test(code)) && + readNearestPackageJson(path.dirname(file))?.type !== 'module' + ) { + return `\`${script}\` mixes import or export with CommonJS globals such as __dirname, require, or module.exports, and \`vpx\` runs it as an ES module; use import.meta.dirname, import.meta.filename, createRequire(), or export`; + } + return undefined; +} + +/** `source` without comments; a string that only looks like one, as in `'http://x'`, stays. */ +function stripComments(source: string): string { + return source.replaceAll( + /("(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'|`(?:\\.|[^`\\])*`)|\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, + (_, string: string | undefined) => string ?? '', + ); +} + +/** + * Whether `code`, without comments, uses a CommonJS global it does not declare or import + * at module level, as in `const require = createRequire(import.meta.url)` or `import + * module from 'node:module'`. A declaration inside a function, which an unindented line + * does not start, covers only that function. + */ +function usesCommonJsGlobals(code: string): boolean { + return COMMONJS_GLOBALS.some( + ([name, use]) => + use.test(code) && + !new RegExp( + `^(?:(?:export\\s+)?(?:const|let|var|function|class)\\s+${name}\\b|import\\s+(?:\\*\\s+as\\s+)?${name}\\b)`, + 'm', + ).test(code), + ); +} + +/** + * Why `vpx` cannot run what tsx or `node --import tsx` runs: `positionals` are the entry + * and its arguments, or with `--test`, the test files; `preloads` are local `--require` + * and `--import` modules, which load first. + */ +function runKeepReason( + cwd: string | undefined, + test: boolean, + positionals: readonly string[], + preloads: readonly string[], +): string | undefined { + const [entry] = positionals; + if (entry === undefined) { + return test ? KEEP_TEST_GLOBS : KEEP_REPL; + } + // tsx finds `src/index.ts` for `src/index`; `vpx` reads a name without an extension as a package. + if (!test && !SCRIPT_EXTENSIONS.some((extension) => entry.endsWith(extension))) { + return `\`${entry}\` has no file extension, which \`vpx\` needs to run it as a script`; + } + const scripts = test ? positionals.filter((value) => !value.startsWith('-')) : [entry]; + for (const script of [...preloads, ...scripts]) { + const reason = scriptKeepReason(cwd, script); + if (reason) { + return reason; + } + } + return undefined; +} + +function rewriteTsx( + rewrite: Rewrite, + tokens: LiteralCommandToken[], + first: number, + start: number, + keepReason: string | undefined, +): void { + if (keepReason) { + rewrite.kept.push(keepReason); + return; + } + let index = start + 1; + const watch = tokens[index]?.value === 'watch'; + if (watch) { + index++; + } + const scanned = scanOptions(tokens, index, true); + const reason = runReason(rewrite.cwd, tokens, scanned, watch); + if (reason) { + rewrite.kept.push(reason); + return; + } + rewrite.edits.push( + { start: tokens[first].start, end: tokens[index - 1].end, text: watch ? 'vpx --watch' : 'vpx' }, + ...scanned.removals, + ); +} + +function rewriteNodeLoader( + rewrite: Rewrite, + tokens: LiteralCommandToken[], + first: number, + start: number, + keepReason: string | undefined, +): void { + const scanned = scanOptions(tokens, start + 1, false); + if (!scanned.loaders) { + // `NODE_OPTIONS='--import tsx' node a.ts` loads tsx without a flag of its own. + if (keepReason === KEEP_NODE_OPTIONS) { + rewrite.kept.push(keepReason); + } + return; + } + const reason = keepReason ?? runReason(rewrite.cwd, tokens, scanned, false); + if (reason) { + rewrite.kept.push(reason); + return; + } + rewrite.edits.push( + { start: tokens[first].start, end: tokens[start].end, text: 'vpx' }, + ...scanned.removals, + ); +} + +/** What the options of `tsx` or `node` before the entry say about a run. */ +interface ScannedOptions { + /** Where the entry, or with `--test` the test files, start. */ + entryIndex: number; + /** Removals of tsx loader flags, and for tsx, of `--no-cache`. */ + removals: Edit[]; + loaders: number; + /** Local `--require` and `--import` modules, which load before the scripts. */ + preloads: string[]; + keepReason?: string; + test: boolean; + watch: boolean; +} + +/** Read the options of `tsx` (`tsx` true) or `node` from `tokens[index]` up to the entry. */ +function scanOptions(tokens: LiteralCommandToken[], index: number, tsx: boolean): ScannedOptions { + const scanned: ScannedOptions = { + entryIndex: tokens.length, + removals: [], + loaders: 0, + preloads: [], + test: false, + watch: false, + }; + for (; index < tokens.length; index++) { + const { value } = tokens[index]; + if (value === '--' || !value.startsWith('-')) { + scanned.entryIndex = value === '--' ? index + 1 : index; + break; + } + const name = optionName(value); + const inline = value.includes('='); + if (tsx && name === '--no-cache') { + // `vpx` has no transform cache, and Node.js would reject the flag. + scanned.removals.push(removal(tokens, index)); + continue; + } + if (LOADER_OPTIONS.has(name)) { + const loader = inline ? value.slice(value.indexOf('=') + 1) : tokens[index + 1]?.value; + if (loader !== undefined && TSX_LOADER.test(loader)) { + // A tsx loader would compete with the hooks of `vpx`. + scanned.removals.push({ + ...removal(tokens, index), + end: tokens[inline ? index : index + 1].end, + }); + scanned.loaders++; + } else if (loader !== undefined && (loader.startsWith('.') || path.isAbsolute(loader))) { + scanned.preloads.push(loader); + } + if (!inline) { + index++; + } + continue; + } + scanned.keepReason ??= KEEP_OPTIONS.get(name); + scanned.test ||= name === '--test'; + scanned.watch ||= name === '--watch'; + if ( + !inline && + (NODE_OPTIONS_WITH_VALUE.has(name) || + (tsx && (WATCH_OPTIONS_WITH_VALUE.has(name) || name === '--tsconfig'))) + ) { + index++; + } + } + return scanned; +} + +/** Why `vpx` cannot run what the scanned options and the scripts after them run. */ +function runReason( + cwd: string | undefined, + tokens: LiteralCommandToken[], + scanned: ScannedOptions, + watch: boolean, +): string | undefined { + if (scanned.keepReason) { + return scanned.keepReason; + } + if ((watch || scanned.watch) && scanned.test) { + return KEEP_WATCH_TEST; + } + return runKeepReason( + cwd, + scanned.test, + tokens.slice(scanned.entryIndex).map(({ value }) => value), + scanned.preloads, + ); +} + +/** + * Rewrite a command that a launcher runs, given as one quoted argument. `inherited` keeps + * tsx for it, as when the launcher passes on `TSX_TSCONFIG_PATH`. + */ +function rewriteQuotedCommand( + rewrite: Rewrite, + token: LiteralCommandToken, + inherited: string | undefined, +): void { + const raw = rewrite.command.slice(token.start, token.end); + const quote = raw[0]; + if ((quote !== '"' && quote !== "'") || raw.length < 2 || !raw.endsWith(quote)) { + return; + } + const inner = rewriteTsxCommand(token.value, rewrite.cwd); + rewrite.kept.push(...inner.kept); + if (inner.command === token.value) { + return; + } + if (inherited) { + rewrite.kept.push(inherited); + } else if (!inner.command.includes(quote)) { + rewrite.edits.push({ + start: token.start, + end: token.end, + text: `${quote}${inner.command}${quote}`, + }); + } +} + +/** The commands in a scripts or lint-staged record. */ +function recordCommands(record: unknown): string[] { + if (!isPlainRecord(record)) { + return []; + } + return Object.values(record) + .flatMap((value) => (Array.isArray(value) ? value : [value])) + .filter((command): command is string => typeof command === 'string'); +} + +/** The commands a package.json runs: its scripts and lint-staged tasks. */ +function packageCommands(pkg: Record): string[] { + return [...recordCommands(pkg.scripts), ...recordCommands(pkg['lint-staged'])]; +} + +function lintStagedJsonFiles(dir: string): string[] { + return LINT_STAGED_JSON_CONFIG_FILES.map((name) => path.join(dir, name)).filter( + (file) => fs.existsSync(file) && isJsonFile(file), + ); +} + +/** + * A package that lists tsx outside devDependencies keeps it: its published code or + * production scripts can need tsx, and `vpx` comes from the vite-plus devDependency. + */ +function ownsTsxAtRuntime(pkg: DependencyBag): boolean { + return ( + pkg.dependencies?.tsx !== undefined || + pkg.optionalDependencies?.tsx !== undefined || + pkg.peerDependencies?.tsx !== undefined + ); +} + +function packageDirs(rootDir: string, packages?: readonly WorkspacePackage[]): string[] { + return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; +} + +/** + * Whether a script, lint-staged task, or `#!/usr/bin/env tsx` shebang runs tsx in a form + * `vpx` replaces. + */ +export function detectTsxProject(rootDir: string, packages?: readonly WorkspacePackage[]): boolean { + return packageDirs(rootDir, packages).some((dir) => { + const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')); + if (!pkg || ownsTsxAtRuntime(pkg)) { + return false; + } + const commands = [ + ...packageCommands(pkg), + ...lintStagedJsonFiles(dir).flatMap((file) => recordCommands(readJsonFile(file))), + ]; + return ( + commands.some((command) => rewriteTsxCommand(command, dir).command !== command) || + sourceTreeMatches(dir, (content, file) => { + const result = rewriteTsxShebang(content, file); + return result !== undefined && 'content' in result; + }) + ); + }); +} + +/** Rewrite each command of a scripts or lint-staged record; returns whether one changed. */ +function rewriteCommandRecord( + record: Record, + label: string, + cwd: string, + report?: MigrationReport, +): boolean { + let edited = false; + for (const [key, value] of Object.entries(record)) { + const commands: unknown[] = Array.isArray(value) ? value : [value]; + const rewritten = commands.map((command) => { + if (typeof command !== 'string') { + return command; + } + const result = rewriteTsxCommand(command, cwd); + for (const reason of result.kept) { + warnMigration(`Kept tsx in ${label} "${key}": ${reason}.`, report); + } + return result.command; + }); + if (rewritten.some((command, index) => command !== commands[index])) { + record[key] = Array.isArray(value) ? rewritten : rewritten[0]; + edited = true; + } + } + return edited; +} + +/** + * The `vpx` version of a script file's tsx shebang, or why it keeps tsx; undefined when + * the file has no `#!/usr/bin/env tsx` shebang. + */ +function rewriteTsxShebang( + content: string, + file: string, +): { content: string } | { reason: string } | undefined { + const match = TSX_ENV_SHEBANG.exec(content); + if (!match) { + return undefined; + } + const [line, prefix, command] = match; + // The kernel runs the shebang's command with the file appended. + const result = rewriteTsxCommand(`${command.trimEnd()} ${SHEBANG_ENTRY}`); + const reason = result.kept[0] ?? scriptKeepReason(path.dirname(file), `./${path.basename(file)}`); + if (reason) { + return { reason }; + } + const vpxCommand = result.command.slice(0, -` ${SHEBANG_ENTRY}`.length); + return { content: `${prefix}${vpxCommand}${content.slice(line.length)}` }; +} + +/** Point `#!/usr/bin/env tsx` shebangs of a package's script files at `vpx`. */ +function rewriteTsxShebangs(dir: string, report?: MigrationReport): boolean { + let changed = false; + // The callback never reports a match, so the walk visits every file. + sourceTreeMatches(dir, (content, file) => { + const result = rewriteTsxShebang(content, file); + if (result && 'reason' in result) { + warnMigration( + `Kept tsx in the shebang of ${displayRelative(file)}: ${result.reason}.`, + report, + ); + } else if (result) { + fs.writeFileSync(file, result.content); + changed = true; + } + return false; + }); + return changed; +} + +/** + * What still runs or imports tsx under `dir`, or undefined. The scan crosses into nested + * packages, which can resolve this package's tsx, and reads build output, which can + * still load it. + */ +function findTsxUse(dir: string): string | undefined { + let found: string | undefined; + sourceTreeMatches( + dir, + (content, file) => { + if (LOCKFILES.has(path.basename(file))) { + return false; + } + const uses = fileUsesTsx(content, file); + if (uses) { + found = `${displayRelative(file)} still uses tsx`; + } + return uses; + }, + { + crossPackageBoundaries: true, + extensions: REFERENCE_EXTENSIONS, + includeExtensionless: true, + includePackageReferences: true, + // Scripts, `imports` aliases, and tool settings such as ava's `nodeArguments`. + matchesPackage: (packageDir, pkg) => { + const field = Object.entries(pkg).find( + ([key, value]) => + !IGNORED_FIELDS.has(key) && + jsonUsesTsx(value, ['imports', 'lint-staged', 'scripts'].includes(key)), + )?.[0]; + if (field !== undefined) { + found = `\`${field}\` in ${displayRelative(path.join(packageDir, 'package.json'))} still uses tsx`; + } + return field !== undefined; + }, + skipDirs: USE_SCAN_SKIP_DIRS, + }, + ); + return found; +} + +/** Whether `file` runs, imports, or loads tsx. */ +function fileUsesTsx(content: string, file: string): boolean { + if (TSX_SHEBANG.test(content)) { + return true; + } + // lint-staged runs a bare `tsx` task too, with the staged files as its arguments. + const name = path.basename(file); + if ( + LINT_STAGED_JSON_CONFIG_FILES.some((config) => config === name) && + jsonUsesTsx(parseJsonc(content), true) + ) { + return true; + } + const extension = path.extname(file); + if (extension === '.json' || extension === '.jsonc') { + return jsonUsesTsx(parseJsonc(content), false); + } + if (CODE_EXTENSIONS.has(extension)) { + return TSX_CODE_REFERENCE.test(content); + } + // An extensionless file can be JavaScript, such as an executable, as well as shell. + return TSX_TEXT_REFERENCE.test(content) || (extension === '' && TSX_CODE_REFERENCE.test(content)); +} + +/** + * Whether a JSON value holds a string that runs or loads tsx: a tsx command with an + * argument, a loader flag such as `--import=tsx` or `["--import", "tsx"]`, or a subpath + * such as `tsx/cjs`. A bare `tsx` counts only in `commands` (scripts, lint-staged tasks, + * and `imports` aliases) and as a loader setting such as Mocha's `"import": "tsx"`; + * elsewhere it is often a file extension, as in `moduleFileExtensions`. + */ +function jsonUsesTsx(value: unknown, commands: boolean): boolean { + if (typeof value === 'string') { + return (commands && TSX_COMMAND.test(value)) || TSX_CONFIG_VALUE.test(value); + } + if (Array.isArray(value)) { + return value.some( + (item, index) => + jsonUsesTsx(item, commands) || + (typeof item === 'string' && + LOADER_OPTIONS.has(item) && + TSX_LOADER.test(String(value[index + 1]))), + ); + } + return ( + isPlainRecord(value) && + Object.entries(value).some( + ([key, item]) => + jsonUsesTsx(item, commands) || + (LOADER_SETTINGS.has(key) && + [item].flat().some((loader) => TSX_LOADER.test(String(loader)))), + ) + ); +} + +/** + * Move tsx scripts to `vpx`: rewrite package.json scripts, lint-staged tasks, and + * `#!/usr/bin/env tsx` shebangs, then remove the tsx devDependency from each package + * where nothing uses tsx anymore. + */ +export function migrateTsxToVpx( + rootDir: string, + packages?: readonly WorkspacePackage[], + report?: MigrationReport, +): { changed: boolean; dependencyRemoved: boolean } { + let changed = false; + const migrated: string[] = []; + for (const dir of packageDirs(rootDir, packages)) { + const packageJsonPath = path.join(dir, 'package.json'); + const pkg = readPackageJsonIfExists(packageJsonPath); + if (!pkg) { + continue; + } + if (ownsTsxAtRuntime(pkg)) { + if (packageCommands(pkg).some((c) => TSX_COMMAND.test(c))) { + warnMigration( + `Kept tsx in ${displayRelative(packageJsonPath)}: it is listed in dependencies, optionalDependencies, or peerDependencies there, and \`vpx\` comes from the vite-plus devDependency.`, + report, + ); + } + continue; + } + migrated.push(dir); + editJsonFile(packageJsonPath, (json) => { + let edited = false; + for (const field of ['scripts', 'lint-staged']) { + const record = json[field]; + if ( + isPlainRecord(record) && + rewriteCommandRecord(record, `${displayRelative(packageJsonPath)} ${field}`, dir, report) + ) { + edited = true; + } + } + changed ||= edited; + return edited ? json : undefined; + }); + for (const file of lintStagedJsonFiles(dir)) { + editJsonFile(file, (config) => { + const edited = rewriteCommandRecord(config, displayRelative(file), dir, report); + changed ||= edited; + return edited ? config : undefined; + }); + } + if (rewriteTsxShebangs(dir, report)) { + changed = true; + } + } + + // Remove the dependency once every package is rewritten: the root's tsx can serve + // any package, so its scan covers the whole workspace. + let dependencyRemoved = false; + for (const dir of migrated) { + const packageJsonPath = path.join(dir, 'package.json'); + if (readPackageJsonIfExists(packageJsonPath)?.devDependencies?.tsx === undefined) { + continue; + } + const use = findTsxUse(dir); + if (use) { + warnMigration( + `Kept the tsx devDependency in ${displayRelative(packageJsonPath)}: ${use}.`, + report, + ); + continue; + } + editJsonFile(packageJsonPath, (json) => { + delete json.devDependencies!.tsx; + return json; + }); + dependencyRemoved = true; + } + if (report && changed) { + report.tsxMigrated = true; + } + return { changed: changed || dependencyRemoved, dependencyRemoved }; +} + +export async function confirmTsxMigration(interactive: boolean): Promise { + if (interactive) { + const confirmed = await prompts.confirm({ + message: + 'Migrate tsx scripts to vpx?\n ' + + styleText( + 'gray', + "vpx is Vite+'s built-in TypeScript script runner: `tsx ` becomes `vpx `, `tsx watch` becomes `vpx --watch`, and the tsx dependency is removed once nothing uses it.", + ), + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + return confirmed; + } + prompts.log.info('tsx scripts detected. Auto-migrating to vpx...'); + return true; +} diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index eebf554c9f..9893f56790 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -25,6 +25,7 @@ export interface MigrationReport { eslintMigrated: boolean; prettierMigrated: boolean; tsupMigrated: boolean; + tsxMigrated: boolean; nodeVersionFileMigrated: boolean; setupVpVersionUpdatedFileCount: number; gitHooksConfigured: boolean; @@ -50,6 +51,7 @@ export function createMigrationReport(): MigrationReport { rewrittenImportErrors: [], eslintMigrated: false, tsupMigrated: false, + tsxMigrated: false, prettierMigrated: false, nodeVersionFileMigrated: false, setupVpVersionUpdatedFileCount: 0, diff --git a/rfcs/vpx-script-execution.md b/rfcs/vpx-script-execution.md index c633457a17..33471ef022 100644 --- a/rfcs/vpx-script-execution.md +++ b/rfcs/vpx-script-execution.md @@ -181,6 +181,15 @@ The loader is resolved from the project's `vite-plus` first (`JsExecutor::resolv For a script, the local bin and the global shim resolve the same loader and Node.js; the difference is the local bin's own Node.js startup before it execs the script, about 23–30 ms against the global shim's 5–10 ms ([Performance](#performance)). +## Migration from tsx + +`vp migrate` rewrites tsx commands to `vpx` as an optional migration, like Prettier and tsup: always on a fresh project, and with `--full` on one already using Vite+ (`packages/cli/src/migration/migrator/tsx.ts`; behavior in [Migration Rules](../docs/guide/migrate-rules.md#tsx-to-vpx)). + +- It reads commands with the same literal-command parser as the other script rewrites and finds the script the way `vpx` does, with the shared value-taking option lists. Commands it cannot read, and forms `vpx` cannot run (eval, `--check`, the REPL, `--test` without files or in watch mode, an entry without an extension), keep tsx with a warning. +- It has no Node.js check of its own: `vpx` needs the `engines.node` range of `vite-plus` (`SUPPORTED_NODE_RANGE` in `script-hooks.ts`, which a unit test keeps equal), and migration already upgrades incompatible runtime pins to that range for Vite+ itself. +- The `tsx` devDependency goes only when no scripts, imports (including build output and `package.json#imports` aliases), shebangs, tool settings such as `--import=tsx`, CI workflows, git hooks, or shell scripts use tsx anymore, including in nested packages that can resolve it. A package that lists tsx in `dependencies`, `optionalDependencies`, or `peerDependencies` keeps it, since `vpx` comes from a devDependency. +- It reads the scripts a command runs, and its local preloads, and keeps tsx when one does not exist yet, as when an earlier command generates it, and when `vpx` would fail on one: a `require()` of `./a.js` that only tsx resolves to `a.ts`, a `.cts` file with `import` or `export`, and a file that mixes `import` with `__dirname`, `__filename`, `require()`, or `module.exports` in a package without `"type": "module"`. tsx compiles the last two to CommonJS; `vpx` rejects the first of them and runs the second as an ES module. These checks are exact and local. The migration skips globs and scripts after a shell state change, and does not look for differences that need module-graph or tsconfig analysis (imports of the named files, standard decorators, tsconfig `paths` in `require()`, a tsconfig `module` of `node16` or `nodenext`); `vpx` reports those when the script runs. + ## Native Side oxc-node is vendored the way Rolldown is: @@ -356,7 +365,7 @@ Package mode through the global `vpx` is unchanged. What changes: ## Future Enhancements -- **`vp migrate` support**: rewrite `tsx` invocations in `package.json` scripts to `vpx`, only when the project's Node.js satisfies the script-mode range and for the cases under "Coming from tsx" in the guide, and remove the devDependency. `ts-node` (which type-checks by default), `esno`, and `jiti` need their own rules. +- **`vp migrate` rules for other runners**: `ts-node` (which type-checks by default), `esno`, and `jiti` need their own rules. - **Public loader entry**: `node --import vite-plus/register ./a.ts`, for tools that must start `node` themselves. - **TypeScript eval and REPL**, once oxc-node transforms eval input. - **Opt-in transform cache**, keyed like tsx's.