diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/.fixture/oxc-cli/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/.fixture/oxc-cli/package.json new file mode 100644 index 0000000000..c728cf75ca --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/.fixture/oxc-cli/package.json @@ -0,0 +1,4 @@ +{ + "name": "@oxc-node/cli", + "version": "1.0.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/.fixture/oxc-core/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/.fixture/oxc-core/package.json new file mode 100644 index 0000000000..aeb620d4a9 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/.fixture/oxc-core/package.json @@ -0,0 +1,4 @@ +{ + "name": "@oxc-node/core", + "version": "1.0.0" +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/package.json b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/package.json new file mode 100644 index 0000000000..7653264ab4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/package.json @@ -0,0 +1,17 @@ +{ + "name": "migration-oxnode", + "type": "module", + "scripts": { + "dev": "oxnode watch --clear-screen=false ./scripts/seed.ts", + "seed": "oxnode -C dev ./scripts/seed.ts", + "loader": "node --import @oxc-node/core/register ./scripts/seed.ts", + "loader-eval": "node --import=@oxc-node/core/register.mjs --eval \"import('./scripts/seed.ts')\"", + "loader-test": "node --import @oxc-node/core/register --test --test-reporter=dot ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--import @oxc-node/core/register' node ./scripts/seed.ts" + }, + "devDependencies": { + "@oxc-node/cli": "file:.fixture/oxc-cli", + "@oxc-node/core": "file:.fixture/oxc-core", + "vite": "^7.0.0" + } +} diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/scripts/release.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/scripts/release.ts new file mode 100644 index 0000000000..e7cb60d6cb --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/scripts/release.ts @@ -0,0 +1,3 @@ +#!/usr/bin/env oxnode +const version: string = '1.0.0'; +console.log(version); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/scripts/seed.ts b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/scripts/seed.ts new file mode 100644 index 0000000000..4d62cf3acd --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/scripts/seed.ts @@ -0,0 +1,4 @@ +enum State { + Ready = 'ready', +} +console.log(State.Ready); diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots.toml new file mode 100644 index 0000000000..d315bdd03e --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots.toml @@ -0,0 +1,34 @@ +[[case]] +name = "migration_oxnode_scripts" +vp = "local" +comment = "The oxc-node dependencies use local stub packages. Migrated scripts run the bundled loader." +steps = [ + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "oxnode commands and shebangs migrate to vpx; preloads keep Node.js" }, + ["vpt", "print-file", "package.json"], + ["vpt", "print-file", "scripts/release.ts"], + { argv = ["vp", "run", "seed"], comment = "the migrated command runs TypeScript with Node conditions" }, + { argv = ["vp", "run", "loader"], comment = "the migrated Node loader command runs without standalone oxc-node" }, + { argv = ["vp", "run", "loader-eval"], comment = "the migrated preload keeps native Node.js eval behavior" }, + ["vp", "run", "loader-test"], +] + +[[case]] +name = "migration_oxnode_kept" +vp = "local" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "scripts.node-watch", "oxnode --watch ./scripts/seed.ts"], snapshot = false }, + { argv = ["vpt", "write-file", "scripts/importer.ts", "import '@oxc-node/core/register';\n"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive", "--no-hooks", "--no-agent", "--no-editor"], comment = "Node watch mode and remaining loader imports keep the dependencies" }, + ["vpt", "print-file", "package.json"], +] + +[[case]] +name = "migration_oxnode_already_vite_plus" +vp = "local" +steps = [ + { argv = ["vpt", "json-edit", "package.json", "devDependencies.vite-plus", "latest"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"], comment = "a plain upgrade leaves oxnode migration to --full" }, + { argv = ["vp", "migrate", "--no-interactive", "--full", "--no-hooks", "--no-agent", "--no-editor"], comment = "--full migrates oxnode on an existing Vite+ project" }, + ["vpt", "print-file", "package.json"], + ["vp", "run", "seed"], +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_already_vite_plus.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_already_vite_plus.md new file mode 100644 index 0000000000..d2e347e7f4 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_already_vite_plus.md @@ -0,0 +1,64 @@ +# migration_oxnode_already_vite_plus + +## `vpt json-edit package.json devDependencies.vite-plus latest` + + +## `vp migrate --no-interactive` + +a plain upgrade leaves oxnode migration 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 oxnode on an existing Vite+ project + +``` +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. +● oxnode scripts detected. Auto-migrating to Vite+... +◇ Updated . to Vite+ +• Node pnpm +• Dependencies: + vite → +• oxnode scripts migrated to Vite+ +``` + +## `vpt print-file package.json` + +``` +{ + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-oxnode", + "scripts": { + "dev": "vpx --watch --clear-screen=false ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--import vite-plus/register' node ./scripts/seed.ts", + "loader": "node --import vite-plus/register ./scripts/seed.ts", + "loader-eval": "node --import=vite-plus/register --eval \"import('./scripts/seed.ts')\"", + "loader-test": "node --import vite-plus/register --test --test-reporter=dot ./scripts/seed.ts", + "seed": "vpx -C dev ./scripts/seed.ts" + }, + "type": "module" +} +``` + +## `vp run seed` + +``` +$ vpx -C dev ./scripts/seed.ts ⊘ cache disabled +ready +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_kept.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_kept.md new file mode 100644 index 0000000000..a627f83555 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_kept.md @@ -0,0 +1,49 @@ +# migration_oxnode_kept + +## `vpt json-edit package.json scripts.node-watch 'oxnode --watch ./scripts/seed.ts'` + + +## `vpt write-file scripts/importer.ts 'import '\''@oxc-node/core/register'\''; +'` + + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +Node watch mode and remaining loader imports keep the dependencies + +``` +VITE+ - The Unified Toolchain for the Web + +● oxnode scripts detected. Auto-migrating to Vite+... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• oxnode scripts migrated to Vite+ +! Warnings: + - Kept oxnode in package.json scripts "node-watch": oxnode --watch starts the Node.js watcher, while `vpx --watch` uses its own watcher; use oxnode watch to migrate it. + - Kept the @oxc-node/cli / @oxc-node/core devDependency in package.json: `scripts` in package.json still uses oxnode. +``` + +## `vpt print-file package.json` + +``` +{ + "devDependencies": { + "@oxc-node/cli": "file:.fixture/oxc-cli", + "@oxc-node/core": "file:.fixture/oxc-core", + "vite": "catalog:", + "vite-plus": "catalog:" + }, + "name": "migration-oxnode", + "scripts": { + "dev": "vpx --watch --clear-screen=false ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--import vite-plus/register' node ./scripts/seed.ts", + "loader": "node --import vite-plus/register ./scripts/seed.ts", + "loader-eval": "node --import=vite-plus/register --eval \"import('./scripts/seed.ts')\"", + "loader-test": "node --import vite-plus/register --test --test-reporter=dot ./scripts/seed.ts", + "node-watch": "oxnode --watch ./scripts/seed.ts", + "seed": "vpx -C dev ./scripts/seed.ts" + }, + "type": "module" +} +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_scripts.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_scripts.md new file mode 100644 index 0000000000..42d21720ba --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_oxnode/snapshots/migration_oxnode_scripts.md @@ -0,0 +1,80 @@ +# migration_oxnode_scripts + +The oxc-node dependencies use local stub packages. Migrated scripts run the bundled loader. + +## `vp migrate --no-interactive --no-hooks --no-agent --no-editor` + +oxnode commands and shebangs migrate to vpx; preloads keep Node.js + +``` +VITE+ - The Unified Toolchain for the Web + +● oxnode scripts detected. Auto-migrating to Vite+... +◇ Migrated . to Vite+ +• Node pnpm +• 1 config update applied +• oxnode scripts migrated to Vite+ +``` + +## `vpt print-file package.json` + +``` +{ + "name": "migration-oxnode", + "type": "module", + "scripts": { + "dev": "vpx --watch --clear-screen=false ./scripts/seed.ts", + "seed": "vpx -C dev ./scripts/seed.ts", + "loader": "node --import vite-plus/register ./scripts/seed.ts", + "loader-eval": "node --import=vite-plus/register --eval \"import('./scripts/seed.ts')\"", + "loader-test": "node --import vite-plus/register --test --test-reporter=dot ./scripts/seed.ts", + "env-loader": "NODE_OPTIONS='--import vite-plus/register' node ./scripts/seed.ts" + }, + "devDependencies": { + "vite": "catalog:", + "vite-plus": "catalog:" + } +} +``` + +## `vpt print-file scripts/release.ts` + +``` +#!/usr/bin/env vpx +const version: string = '1.0.0'; +console.log(version); +``` + +## `vp run seed` + +the migrated command runs TypeScript with Node conditions + +``` +$ vpx -C dev ./scripts/seed.ts ⊘ cache disabled +ready +``` + +## `vp run loader` + +the migrated Node loader command runs without standalone oxc-node + +``` +$ node --import vite-plus/register ./scripts/seed.ts ⊘ cache disabled +ready +``` + +## `vp run loader-eval` + +the migrated preload keeps native Node.js eval behavior + +``` +$ node --import=vite-plus/register --eval "import('./scripts/seed.ts')" ⊘ cache disabled +ready +``` + +## `vp run loader-test` + +``` +$ node --import vite-plus/register --test --test-reporter=dot ./scripts/seed.ts ⊘ cache disabled +. +``` diff --git a/docs/guide/migrate-rules.md b/docs/guide/migrate-rules.md index bc8f63147c..bff7150b9b 100644 --- a/docs/guide/migrate-rules.md +++ b/docs/guide/migrate-rules.md @@ -262,6 +262,7 @@ Migration rewrites commands provided by the Vite+ toolchain in `package.json` sc | `prettier` | `vp fmt`, when its optional migration runs | | `tsup` | `vp pack`, when its optional migration runs | | `tsx` | `vpx`, when its optional migration runs | +| `oxnode` | `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. 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 `--`: @@ -303,6 +304,26 @@ Tool configurations and source files need manual edits. For example, change `nod See [Coming from tsx](/guide/vpx#coming-from-tsx) for the other differences. +### oxnode to `vpx` + +Migration offers to replace `oxnode`, the command from `@oxc-node/cli`, with `vpx`. It rewrites `package.json` scripts, lint-staged tasks in `package.json` and JSON files, and `#!/usr/bin/env oxnode` shebangs. On a project that already uses Vite+, run `vp migrate --full` to include this migration. + +| Before | After | +| --------------------------------------------------------- | ---------------------------------------------------- | +| `oxnode ./scripts/seed.ts` | `vpx ./scripts/seed.ts` | +| `oxnode -C dev ./build.ts` | `vpx -C dev ./build.ts` | +| `oxnode watch --include "config/*.json" ./server.ts` | `vpx --watch --include "config/*.json" ./server.ts` | +| `node --import @oxc-node/core/register ./scripts/seed.ts` | `node --import vite-plus/register ./scripts/seed.ts` | +| `NODE_OPTIONS='--import @oxc-node/core/register' mocha` | `NODE_OPTIONS='--import vite-plus/register' mocha` | + +The `node --import @oxc-node/core/register` and `register.mjs` forms keep Node.js, its wrappers, arguments, and native watch, eval, and test modes. Literal `NODE_OPTIONS` assignments also use the public preload. Commands that use the `oxnode` executable follow the `vpx` rules instead: `oxnode --watch` stays unchanged because it starts Node.js's watcher, while `oxnode watch` and `vpx --watch` use oxc-node's watcher. + +Commands that explicitly set `OXC_TRANSFORM_ALL` to anything other than `typescript` stay unchanged. Nonempty `VP_SCRIPT_TSCONFIG` and `TSX_TSCONFIG_PATH` assignments also stay unchanged because they would change config selection. Commands that read an environment file stay unchanged because it can select a different transform mode. Migration checks the named script and local preloads for standalone oxc-node API usage, which would configure a different loader after migration; it does not follow their imports. + +Node.js preload commands also stay unchanged when another startup preload can change loader settings, or inline code still calls standalone oxc-node APIs. + +The `@oxc-node/cli` and `@oxc-node/core` development dependencies are removed only when no remaining use needs them. Loader references, API imports, CI commands, hooks, and shell scripts can keep these dependencies installed. Runtime and peer dependencies stay in place. + ## 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 fb2c37af05..eba4305cfe 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, tsup, and tsx setups into Vite+. +This command is the starting point for consolidating separate Vite, Vitest, Oxlint, Oxfmt, ESLint, Prettier, tsdown, tsup, tsx, and oxnode 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 e94a531033..d9cd0ab763 100644 --- a/docs/guide/vpx.md +++ b/docs/guide/vpx.md @@ -228,6 +228,10 @@ JSX that depends on a compiler plugin is not supported. `vpx` compiles JSX only - **`node --import tsx`**: replace it with [`node --import vite-plus/register`](#nodejs-preload), including in tool settings and `NODE_OPTIONS`. - **Other loaders**: replace the tsx preload instead of adding a second loader. Two loaders registered together compete for the same files. +### Coming from oxnode + +`vp migrate` can replace `oxnode ` with `vpx ` and `oxnode watch ` with `vpx --watch `. Both use oxc-node's transformer and watcher. Node.js commands and literal `NODE_OPTIONS` assignments that load `@oxc-node/core/register` use `vite-plus/register` instead. Migration removes the standalone development dependencies only when no remaining commands or imports need them. See [oxnode to `vpx`](/guide/migrate-rules#oxnode-to-vpx). + ## `vp exec` Use `vp exec` to run tools already installed in your project or available in your environment. diff --git a/packages/cli/src/migration/__tests__/oxnode.spec.ts b/packages/cli/src/migration/__tests__/oxnode.spec.ts new file mode 100644 index 0000000000..c0b1eefbfe --- /dev/null +++ b/packages/cli/src/migration/__tests__/oxnode.spec.ts @@ -0,0 +1,399 @@ +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 { + detectOxnodeProject, + migrateOxnodeToVpx, + rewriteOxnodeCommand, +} from '../migrator/oxnode.ts'; +import { createMigrationReport } from '../report.ts'; + +describe('rewriteOxnodeCommand', () => { + it.each([ + ['oxnode ./scripts/build.ts --flag', 'vpx ./scripts/build.ts --flag'], + ['oxnode -C dev ./build.ts', 'vpx -C dev ./build.ts'], + ['oxnode --conditions=dev --inspect ./build.mts', 'vpx --conditions=dev --inspect ./build.mts'], + ['oxnode watch src/server.ts', 'vpx --watch src/server.ts'], + [ + 'oxnode watch --include "config/*.json" --exclude src/gen.ts --tsconfig cli.json --clear-screen=false src/server.ts --port 3000', + 'vpx --watch --include "config/*.json" --exclude src/gen.ts --tsconfig cli.json --clear-screen=false src/server.ts --port 3000', + ], + ['oxnode --test ./test.test.ts', 'vpx --test ./test.test.ts'], + ['npx oxnode a.ts', 'vpx a.ts'], + ['npx --yes oxnode a.ts', 'vpx a.ts'], + ['pnpm exec oxnode a.ts', 'vpx a.ts'], + ['pnpm dlx oxnode a.ts', 'vpx a.ts'], + ['npm exec -- oxnode a.ts', 'vpx a.ts'], + ['yarn oxnode a.ts', 'vpx a.ts'], + ['bunx oxnode a.ts', 'vpx a.ts'], + ['NODE_ENV=dev oxnode a.ts', 'NODE_ENV=dev vpx a.ts'], + [ + 'cross-env OXC_TSCONFIG_PATH=cli.json oxnode a.ts', + 'cross-env OXC_TSCONFIG_PATH=cli.json vpx a.ts', + ], + ['OXC_TRANSFORM_ALL=typescript oxnode a.ts', 'OXC_TRANSFORM_ALL=typescript vpx a.ts'], + ['node --import @oxc-node/core/register a.ts', 'node --import vite-plus/register a.ts'], + [ + 'node --import @oxc-node/core/register --watch a.ts', + 'node --import vite-plus/register --watch a.ts', + ], + [ + 'node --import @oxc-node/core/register --watch-path src a.ts', + 'node --import vite-plus/register --watch-path src a.ts', + ], + ['node --import @oxc-node/core/register --test', 'node --import vite-plus/register --test'], + ['node --import @oxc-node/core/register -e 1', 'node --import vite-plus/register -e 1'], + ['node --import @oxc-node/core/register', 'node --import vite-plus/register'], + [ + 'node --import @oxc-node/core/register src/index', + 'node --import vite-plus/register src/index', + ], + [ + 'pnpm exec node --import=@oxc-node/core/register.mjs a.ts', + 'pnpm exec node --import=vite-plus/register a.ts', + ], + [ + 'NODE_OPTIONS=--import=@oxc-node/core/register node a.ts', + 'NODE_OPTIONS=--import=vite-plus/register node a.ts', + ], + [ + "NODE_OPTIONS='--import @oxc-node/core/register.mjs' mocha", + "NODE_OPTIONS='--import vite-plus/register' mocha", + ], + [ + 'NODE_OPTIONS=--import=@oxc-node/core/register concurrently "node a.ts" "node --test"', + 'NODE_OPTIONS=--import=vite-plus/register concurrently "node a.ts" "node --test"', + ], + [ + 'OXC_TSCONFIG_PATH=cli.json node --import @oxc-node/core/register a.ts', + 'OXC_TSCONFIG_PATH=cli.json node --import vite-plus/register a.ts', + ], + + [ + 'node --import=@oxc-node/core/register.mjs -C dev a.ts', + 'node --import=vite-plus/register -C dev a.ts', + ], + [ + 'node --import vite-plus/register --import @oxc-node/core/register a.ts', + 'node --import vite-plus/register --import vite-plus/register a.ts', + ], + ['oxnode --import @oxc-node/core/register a.ts', 'vpx a.ts'], + ['oxnode a.ts && oxnode watch b.ts', 'vpx a.ts && vpx --watch b.ts'], + ["node -e 'console.log(1)' && oxnode a.ts", "node -e 'console.log(1)' && vpx a.ts"], + ['concurrently "oxnode watch api.ts" "vp dev"', 'concurrently "vpx --watch api.ts" "vp dev"'], + ["nodemon --exec 'oxnode a.ts'", "nodemon --exec 'vpx a.ts'"], + ["sh -c 'oxnode a.ts'", "sh -c 'vpx a.ts'"], + ['cross-env-shell "oxnode a.ts"', 'cross-env-shell "vpx a.ts"'], + ])('rewrites %s', (command, expected) => { + expect(rewriteOxnodeCommand(command)).toEqual({ command: expected, kept: [] }); + expect(rewriteOxnodeCommand(expected)).toEqual({ command: expected, kept: [] }); + }); + + it.each([ + ['oxnode --watch a.ts', 'Node.js watcher'], + ['node -r ./setup.cjs --import @oxc-node/core/register a.ts', 'additional startup preload'], + [ + 'node --require=dotenv/config --import @oxc-node/core/register a.ts', + 'additional startup preload', + ], + [ + 'node --import ./setup.mjs --import @oxc-node/core/register a.ts', + 'additional startup preload', + ], + [ + 'node --import @oxc-node/core/register --import ./setup.ts a.ts', + 'additional startup preload', + ], + [ + 'NODE_OPTIONS="-r ./setup.cjs --import @oxc-node/core/register" node a.ts', + 'additional startup preload', + ], + [ + 'NODE_OPTIONS="--require=dotenv/config --import @oxc-node/core/register" mocha', + 'additional startup preload', + ], + + ['oxnode --watch_path=src a.ts', 'Node.js watcher'], + ['oxnode --node-help a.ts', '--node-help'], + ['oxnode --env-file=.env a.ts', 'env file can configure'], + ['node --import @oxc-node/core/register --env-file=.env a.ts', 'env file can configure'], + ['node --import @oxc-node/core/register --env_file=.env a.ts', 'env file can configure'], + [ + 'node --import @oxc-node/core/register --experimental_config_file=node.json a.ts', + 'config file can load additional hooks', + ], + [ + 'node --import @oxc-node/core/register --config-file=node.json a.ts', + 'config file can load additional hooks', + ], + [ + 'node --import @oxc-node/core/register --experimental-default-config-file a.ts', + 'config file can load additional hooks', + ], + ['OXC_TRANSFORM_ALL=1 node --import @oxc-node/core/register a.ts', 'dependency transforms'], + [ + 'OXC_TRANSFORM_ALL=0 NODE_OPTIONS=--import=@oxc-node/core/register node a.ts', + 'dependency transforms', + ], + [ + 'TSX_TSCONFIG_PATH=cli.json node --import @oxc-node/core/register a.ts', + 'different configuration', + ], + [ + 'VP_SCRIPT_TSCONFIG=cli.json NODE_OPTIONS=--import=@oxc-node/core/register mocha', + 'different configuration', + ], + ['TSX_TSCONFIG_PATH=cli.json oxnode a.ts', 'different configuration'], + [ + 'NODE_OPTIONS=--import=@oxc-node/core/register concurrently "node --import @oxc-node/core/register a.ts" "oxnode b.ts"', + 'hooks that would compete', + ], + + ['oxnode --env-file-if-exists .env a.ts', 'env file can configure'], + ['oxnode --help a.ts', 'different help'], + ['oxnode --version a.ts', 'different versions'], + ["oxnode -e 'console.log(1)'", '--eval or --print'], + ['oxnode -c a.ts', 'shell mode'], + ['oxnode', 'REPL'], + ['oxnode --test', 'test files explicitly'], + ['oxnode watch --test a.test.ts', 'does not run `--test`'], + ['oxnode src/index', 'file extension'], + ['oxnode a.ts | tee output', 'shell syntax'], + ['oxnode $ENTRY', 'shell syntax'], + ['bunx --bun oxnode a.ts', 'does not rewrite'], + ['npx oxnode@1 a.ts', 'does not rewrite'], + ['node --loader @oxc-node/core/esm a.ts', 'does not rewrite --loader'], + ['node --import @oxc-node/core/esm.mjs a.ts', 'does not rewrite --import'], + ['oxnode --require @oxc-node/core/register a.ts', 'does not rewrite --require'], + ['OXC_TRANSFORM_ALL=1 oxnode a.ts', 'dependency transforms'], + ['OXC_TRANSFORM_ALL=0 oxnode a.ts', 'dependency transforms'], + ['OXC_TRANSFORM_ALL=false oxnode a.ts', 'dependency transforms'], + ['OXC_TRANSFORM_ALL= oxnode a.ts', 'dependency transforms'], + ['OXC_TRANSFORM_ALL=TypeScript oxnode a.ts', 'dependency transforms'], + ["OXC_TRANSFORM_ALL=' typescript ' oxnode a.ts", 'dependency transforms'], + ['OXC_TRANSFORM_ALL=1 concurrently "oxnode a.ts"', 'dependency transforms'], + [ + 'cross-env NODE_OPTIONS=--import=@oxc-node/core/register oxnode a.ts', + 'hooks that would compete', + ], + ])('retains %s', (command, reason) => { + expect(rewriteOxnodeCommand(command)).toEqual({ + command, + kept: expect.arrayContaining([expect.stringContaining(reason)]), + }); + }); + + it.each(['vpx a.ts', 'node a.js', 'tsx a.ts', 'oxnode-tools a.ts'])( + 'ignores unrelated command %s', + (command) => { + expect(rewriteOxnodeCommand(command)).toEqual({ command, kept: [] }); + }, + ); +}); + +describe('oxnode inline code', () => { + it.each(['--eval', '--print', '-e', '-p', '-pe'])( + 'retains %s code that configures the standalone loader', + (flag) => { + const code = "require('@oxc-node/core').setTransformDeps('all')"; + for (const command of [ + `node --import @oxc-node/core/register ${flag} "${code}"`, + `NODE_OPTIONS=--import=@oxc-node/core/register node ${flag} "${code}"`, + `node --import @oxc-node/core/register ${flag}="${code}"`, + ...(flag.startsWith('--') + ? [] + : [`node --import @oxc-node/core/register ${flag}"${code}"`]), + ]) { + expect(rewriteOxnodeCommand(command)).toEqual({ + command, + kept: [expect.stringContaining('inline code uses a standalone oxc-node API')], + }); + } + }, + ); +}); + +describe('oxnode migration', () => { + 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)); + }; + 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-oxnode-')); + vi.spyOn(process, 'cwd').mockReturnValue(root); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + }); + + it('rewrites scripts, JSON staged tasks and shebangs, and removes unused packages', () => { + write('package.json', { + scripts: { build: 'oxnode -C dev ./build.ts', dev: 'oxnode watch src/server.ts' }, + 'lint-staged': { '*.ts': ['oxnode scripts/check.ts'] }, + devDependencies: { + '@oxc-node/cli': '^1.0.0', + '@oxc-node/core': '^1.0.0', + typescript: '^6.0.0', + }, + }); + write('.lintstagedrc.json', { '*.md': 'oxnode scripts/docs.ts' }); + write('scripts/release.ts', '#!/usr/bin/env oxnode\nexport {};\n'); + write('scripts/dev.mts', '#!/usr/bin/env -S oxnode -C dev\nexport {};\n'); + const report = createMigrationReport(); + + expect(detectOxnodeProject(root)).toBe(true); + expect(migrateOxnodeToVpx(root, undefined, report)).toEqual({ + changed: true, + dependencyRemoved: true, + }); + expect(readJson('package.json')).toEqual({ + scripts: { build: 'vpx -C dev ./build.ts', dev: 'vpx --watch src/server.ts' }, + 'lint-staged': { '*.ts': ['vpx scripts/check.ts'] }, + devDependencies: { typescript: '^6.0.0' }, + }); + expect(readJson('.lintstagedrc.json')).toEqual({ '*.md': 'vpx scripts/docs.ts' }); + expect(read('scripts/release.ts')).toBe('#!/usr/bin/env vpx\nexport {};\n'); + expect(read('scripts/dev.mts')).toBe('#!/usr/bin/env -S vpx -C dev\nexport {};\n'); + expect(report.oxnodeMigrated).toBe(true); + expect(report.warnings).toEqual([]); + expect(detectOxnodeProject(root)).toBe(false); + expect(migrateOxnodeToVpx(root)).toEqual({ changed: false, dependencyRemoved: false }); + }); + + it.each([ + ['src/api.ts', "import { OxcTransformer } from '@oxc-node/core';\n"], + ['src/importer.ts', "import { createImporter } from '@oxc-node/core/importer';\n"], + ['dist/helpers.js', "import helper from '@oxc-node/core/helpers/typeof';\n"], + ['src/watch.ts', "import { runWatch } from '@oxc-node/cli/watch';\n"], + ['src/spawn.ts', "spawn('oxnode', ['a.ts']);\n"], + ['src/windows.ts', "spawn('OXNODE.CMD', ['a.ts']);\n"], + ['src/path.ts', "spawn('./node_modules/.bin/oxnode', ['a.ts']);\n"], + ['.github/workflows/ci.yml', 'jobs:\n run: npx oxnode scripts/ci.ts\n'], + ['.husky/pre-commit', 'oxnode scripts/check.ts\n'], + ['scripts/release.sh', 'oxnode scripts/release.ts\n'], + ['.mocharc.json', '{"import":"@oxc-node/core/register"}'], + ['.env.local', 'NODE_OPTIONS=--import=@oxc-node/core/register\n'], + ['.env.config.json', 'NODE_OPTIONS=--import=@oxc-node/core/register\n'], + [ + 'ava.config.js', + "export default { nodeArguments: ['--import', '@oxc-node/core/register'] };\n", + ], + ['lint-staged.config.ts', "export default { '*.ts': 'oxnode check.ts' };\n"], + ['bin/release', '#!/usr/bin/env oxnode\nexport {};\n'], + ])('retains dependencies used by %s', (file, content) => { + write('package.json', { + scripts: { build: 'oxnode ./build.ts' }, + devDependencies: { '@oxc-node/cli': '^1.0.0', '@oxc-node/core': '^1.0.0' }, + }); + write(file, content); + const report = createMigrationReport(); + + expect(migrateOxnodeToVpx(root, undefined, report).dependencyRemoved).toBe(false); + expect(readJson('package.json').scripts.build).toBe('vpx ./build.ts'); + expect(readJson('package.json').devDependencies).toEqual({ + '@oxc-node/cli': '^1.0.0', + '@oxc-node/core': '^1.0.0', + }); + expect(report.warnings).toContainEqual(expect.stringContaining(`${file} still uses oxnode`)); + }); + + it.each(['dependencies', 'optionalDependencies', 'peerDependencies'])( + 'preserves a runner needed in %s', + (field) => { + write('package.json', { + scripts: { start: 'oxnode a.ts' }, + [field]: { '@oxc-node/cli': '^1.0.0' }, + }); + const report = createMigrationReport(); + expect(detectOxnodeProject(root)).toBe(true); + expect(migrateOxnodeToVpx(root, undefined, report).changed).toBe(false); + expect(readJson('package.json').scripts.start).toBe('oxnode a.ts'); + expect(report.warnings).toContainEqual(expect.stringContaining('listed in dependencies')); + }, + ); + + it('detects and reports a project with only an unsupported command', () => { + write('package.json', { + scripts: { dev: 'oxnode --watch a.ts' }, + devDependencies: { '@oxc-node/cli': '^1.0.0' }, + }); + const report = createMigrationReport(); + expect(detectOxnodeProject(root)).toBe(true); + expect(migrateOxnodeToVpx(root, undefined, report).changed).toBe(false); + expect(report.warnings).toContainEqual(expect.stringContaining('Node.js watcher')); + expect(report.oxnodeMigrated).toBe(false); + }); + + it('detects a project that uses only the core register loader', () => { + write('package.json', { + scripts: { build: 'node --import @oxc-node/core/register.mjs build.ts' }, + devDependencies: { '@oxc-node/core': '^1.0.0' }, + }); + expect(detectOxnodeProject(root)).toBe(true); + expect(migrateOxnodeToVpx(root)).toEqual({ changed: true, dependencyRemoved: true }); + expect(readJson('package.json').scripts.build).toBe( + 'node --import vite-plus/register build.ts', + ); + }); + + it.each(['oxnode --import ./setup.ts a.ts', 'oxnode ./setup.ts'])( + 'retains %s when a named file configures the standalone loader', + (command) => { + write( + 'setup.ts', + "import { setTsconfigPath } from '@oxc-node/core'; setTsconfigPath('cli.json');\n", + ); + expect(rewriteOxnodeCommand(command, root)).toEqual({ + command, + kept: [expect.stringContaining('standalone oxc-node API')], + }); + }, + ); + + it('retains root dependencies while a workspace or nested package still uses them', () => { + write('package.json', { + devDependencies: { '@oxc-node/cli': '^1.0.0', '@oxc-node/core': '^1.0.0' }, + }); + write('packages/a/package.json', { scripts: { build: 'oxnode build.ts' } }); + write('packages/b/package.json', { scripts: { build: 'oxnode --watch build.ts' } }); + write('packages/a/examples/demo/package.json', { + imports: { '#loader': '@oxc-node/core/importer' }, + }); + const packages = [ + { name: 'a', path: 'packages/a' }, + { name: 'b', path: 'packages/b' }, + ]; + expect(migrateOxnodeToVpx(root, packages).dependencyRemoved).toBe(false); + expect(readJson('packages/a/package.json').scripts.build).toBe('vpx build.ts'); + expect(readJson('packages/b/package.json').scripts.build).toBe('oxnode --watch build.ts'); + expect(readJson('package.json').devDependencies['@oxc-node/core']).toBe('^1.0.0'); + }); + + it('retains the loader referenced by an env file of a separate Node.js command', () => { + write('package.json', { + scripts: { build: 'oxnode build.ts', run: 'node --env-file=.env.local run.ts' }, + devDependencies: { '@oxc-node/cli': '^1.0.0', '@oxc-node/core': '^1.0.0' }, + }); + write('.env.local', 'NODE_OPTIONS=--import=@oxc-node/core/register\n'); + expect(migrateOxnodeToVpx(root).dependencyRemoved).toBe(false); + expect(readJson('package.json').scripts).toEqual({ + build: 'vpx build.ts', + run: 'node --env-file=.env.local run.ts', + }); + expect(readJson('package.json').devDependencies['@oxc-node/core']).toBe('^1.0.0'); + }); +}); diff --git a/packages/cli/src/migration/bin.ts b/packages/cli/src/migration/bin.ts index a9defc251a..695ccd476c 100644 --- a/packages/cli/src/migration/bin.ts +++ b/packages/cli/src/migration/bin.ts @@ -50,6 +50,7 @@ import { collectToolchainVersionChanges, confirmPrettierMigration, confirmTsxMigration, + confirmOxnodeMigration, detectEslintProject, detectFramework, detectNodeVersionManagerFile, @@ -57,6 +58,7 @@ import { detectPrettierProject, detectTsupProject, detectTsxProject, + detectOxnodeProject, detectVitePlusBootstrapPending, detectYarnPnpMode, ensureVitePlusBootstrap, @@ -75,6 +77,7 @@ import { migrateSetupVpVersion, migrateTsupToTsdown, migrateTsxToVpx, + migrateOxnodeToVpx, configureYarnNodeModulesMode, rewriteMonorepo, rewriteStandaloneProject, @@ -285,6 +288,7 @@ interface MigrationPlan extends MigrationSetupPlan { migrateTsup: boolean; tsupConfigFile?: string; migrateTsx: boolean; + migrateOxnode: boolean; fixBaseUrl: boolean; migrateNodeVersionFile: boolean; nodeVersionDetection?: NodeVersionManagerDetection; @@ -382,6 +386,7 @@ function hasExistingVitePlusMigrationCandidates( prettierProject.hasDependency || tsupProject.hasDependency || detectTsxProject(workspaceInfo.rootDir, workspaceInfo.packages) || + detectOxnodeProject(workspaceInfo.rootDir, workspaceInfo.packages) || detectNodeVersionManagerFile(workspaceInfo.rootDir) !== undefined || getFrameworkShimCandidates(workspaceInfo.rootDir, workspaceInfo.packages).length > 0 ); @@ -444,6 +449,8 @@ async function collectMigrationPlan( // 3c. tsx detection + prompt const migrateTsx = detectTsxProject(rootDir, packages) && (await confirmTsxMigration(options.interactive)); + const migrateOxnode = + detectOxnodeProject(rootDir, packages) && (await confirmOxnodeMigration(options.interactive)); // 9. tsconfig baseUrl prompt const fixBaseUrl = hasBaseUrlInWorkspace({ rootDir, packages }) @@ -473,6 +480,7 @@ async function collectMigrationPlan( migrateTsup, tsupConfigFile, migrateTsx, + migrateOxnode, fixBaseUrl, migrateNodeVersionFile, nodeVersionDetection, @@ -584,6 +592,9 @@ function showMigrationSummary(options: { if (report.tsxMigrated) { log(`${styleText('gray', '•')} tsx scripts migrated to Vite+`); } + if (report.oxnodeMigrated) { + log(`${styleText('gray', '•')} oxnode scripts migrated to Vite+`); + } if (report.nodeVersionFileMigrated) { log(`${styleText('gray', '•')} Node version manager file migrated to .node-version`); } @@ -864,6 +875,10 @@ async function executeMigrationPlan( updateMigrationProgress('Migrating tsx scripts'); migrateTsxToVpx(workspaceInfo.rootDir, workspaceInfo.packages, report); } + if (plan.migrateOxnode) { + updateMigrationProgress('Migrating oxnode scripts'); + migrateOxnodeToVpx(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. @@ -1413,6 +1428,25 @@ async function main() { } } + if ( + fullSetup && + detectOxnodeProject(workspaceInfoOptional.rootDir, workspaceInfoOptional.packages) + ) { + if (options.interactive) { + clearMigrationProgress(); + } + if (await confirmOxnodeMigration(options.interactive)) { + updateMigrationProgress('Migrating oxnode scripts'); + const oxnodeResult = migrateOxnodeToVpx( + workspaceInfoOptional.rootDir, + workspaceInfoOptional.packages, + report, + ); + didMigrate ||= oxnodeResult.changed; + needsInstall ||= oxnodeResult.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 c31f63449d..c35e126fd6 100644 --- a/packages/cli/src/migration/migrator.ts +++ b/packages/cli/src/migration/migrator.ts @@ -3,6 +3,8 @@ export * from './migrator/eslint.ts'; export * from './migrator/prettier.ts'; export * from './migrator/tsup.ts'; export * from './migrator/tsx.ts'; +export * from './migrator/script-runner.ts'; +export * from './migrator/oxnode.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 e1c9624115..903a463514 100644 --- a/packages/cli/src/migration/migrator/README.md +++ b/packages/cli/src/migration/migrator/README.md @@ -35,6 +35,8 @@ Pick the file by what a function _does_, not by where it happens to be called. | `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. | +| `oxnode.ts` | oxnode → `vpx` command rules, loader compatibility, and its prompt/warnings. | +| `script-runner.ts` | Shared script-runner command parsing, shebang rewrites, project detection, and conservative dependency retention. | | `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/oxnode.ts b/packages/cli/src/migration/migrator/oxnode.ts new file mode 100644 index 0000000000..6656cb14a7 --- /dev/null +++ b/packages/cli/src/migration/migrator/oxnode.ts @@ -0,0 +1,202 @@ +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 { type WorkspacePackage } from '../../types/index.ts'; +import { cancelAndExit } from '../../utils/prompts.ts'; +import { WATCH_OPTIONS_WITH_VALUE } from '../../vpx-script.ts'; +import { + detectScriptRunnerProject, + migrateScriptRunnerToVpx, + rewriteScriptRunnerCommand, + type ScriptRunnerCommandRewrite, + type ScriptRunnerMigration, +} from '../migrator.ts'; +import { type MigrationReport } from '../report.ts'; + +// Match commands and every core subpath conservatively. Only --import register is +// converted; other loaders and APIs still need the standalone packages. +const OXNODE_COMMAND = + /(?:^|[\s;&|("'=/\\`])oxnode(?:\.(?:cmd|exe|ps1))?(?=$|[@\s;&|)"'`])|@oxc-node\/core(?:\/[^\s'"`]+)?/i; +const OXNODE_LOADER = /^@oxc-node\/core(?:\/.*)?$/; +const OXNODE_PACKAGE_REFERENCE = /@oxc-node\/(?:cli|core)(?=$|[\s/'"`])/; +const OXNODE_CODE_REFERENCE = new RegExp( + [OXNODE_COMMAND.source, OXNODE_PACKAGE_REFERENCE.source].join('|'), + 'i', +); +const OXNODE_TEXT_REFERENCE = new RegExp( + [OXNODE_COMMAND.source, OXNODE_PACKAGE_REFERENCE.source].join('|'), + 'im', +); +const KEEP_NODE_WATCH = + 'oxnode --watch starts the Node.js watcher, while `vpx --watch` uses its own watcher; use oxnode watch to migrate it'; +const KEEP_TRANSFORM_DEPS = + '`OXC_TRANSFORM_ALL` changes dependency transforms, while the Vite+ loader always transforms only TypeScript dependencies'; +const REGISTER_KEEP_OPTIONS = new Map([ + [ + '--env-file', + 'an env file can configure oxc-node hooks or dependency transforms; migrate it manually', + ], + [ + '--env-file-if-exists', + 'an env file can configure oxc-node hooks or dependency transforms; migrate it manually', + ], + ['--config-file', 'a Node.js config file can load additional hooks; migrate it manually'], + [ + '--experimental-config-file', + 'a Node.js config file can load additional hooks; migrate it manually', + ], + [ + '--experimental-default-config-file', + 'a Node.js config file can load additional hooks; migrate it manually', + ], +]); + +function isOxnodeRegister(option: string, value: string): boolean { + return option === '--import' && /^@oxc-node\/core\/register(?:\.mjs)?$/.test(value); +} + +function oxnodeEnvironmentReason(assignments: readonly string[]): string | undefined { + for (const assignment of assignments) { + const separator = assignment.indexOf('='); + const name = assignment.slice(0, separator).toUpperCase(); + const value = assignment.slice(separator + 1); + if (name === 'OXC_TRANSFORM_ALL' && value !== 'typescript') { + return KEEP_TRANSFORM_DEPS; + } + if ((name === 'VP_SCRIPT_TSCONFIG' || name === 'TSX_TSCONFIG_PATH') && value) { + return `\`${name}\` would select a different configuration for the Vite+ loader`; + } + } + return undefined; +} + +const OXNODE_RUNNER: ScriptRunnerMigration = { + name: 'oxnode', + dependencies: ['@oxc-node/cli', '@oxc-node/core'], + commandPattern: OXNODE_COMMAND, + loaderPattern: OXNODE_LOADER, + isLoader: isOxnodeRegister, + isRegister: isOxnodeRegister, + registerEnvKeepReason: oxnodeEnvironmentReason, + registerKeepOptions: REGISTER_KEEP_OPTIONS, + registerPreloadKeepReason(option, value) { + if (option === '--import' && value === 'vite-plus/register') { + return undefined; + } + return 'an additional startup preload can configure oxc-node hooks or dependency transforms; migrate it manually'; + }, + registerInlineCodeKeepReason(code) { + if (OXNODE_PACKAGE_REFERENCE.test(code)) { + return 'inline code uses a standalone oxc-node API or loader that does not configure the bundled Vite+ loader'; + } + return undefined; + }, + envShebang: /^(#!\s*\S*\benv\s+(?:-S\s+)?)(oxnode(?=[ \t\r\n]|$)[^\r\n]*)/, + shebangPattern: /^#![^\n]*[\s/]oxnode(?=\s|$)/, + codeReference: OXNODE_CODE_REFERENCE, + textReference: OXNODE_TEXT_REFERENCE, + configValue: OXNODE_TEXT_REFERENCE, + referenceFileNamePattern: /^\.env(?:\.|$)/, + keepOptions: new Map([ + ...REGISTER_KEEP_OPTIONS, + ['-e', '`vpx` does not run TypeScript passed to --eval or --print'], + ['--eval', '`vpx` does not run TypeScript passed to --eval or --print'], + ['-p', '`vpx` does not run TypeScript passed to --eval or --print'], + ['-pe', '`vpx` does not run TypeScript passed to --eval or --print'], + ['--print', '`vpx` does not run TypeScript passed to --eval or --print'], + ['-c', '`vpx -c` selects package shell mode instead of Node.js syntax checking'], + ['--check', '`vpx` does not transform TypeScript for --check'], + ['-i', '`vpx` does not start a TypeScript REPL'], + ['--interactive', '`vpx` does not start a TypeScript REPL'], + ['--watch', KEEP_NODE_WATCH], + ['--watch-path', KEEP_NODE_WATCH], + ['--node-help', '`vpx` does not support oxnode --node-help'], + ['-h', 'oxnode and `vpx` have different help output'], + ['--help', 'oxnode and `vpx` have different help output'], + ['-v', 'oxnode and `vpx` have different versions'], + ['--version', 'oxnode and `vpx` have different versions'], + ]), + optionsWithValue: new Set([...WATCH_OPTIONS_WITH_VALUE, '--tsconfig']), + testWithoutFilesReason: 'list the test files explicitly before migrating to `vpx --test`', + envKeepReason(assignments) { + const reason = oxnodeEnvironmentReason(assignments); + if (reason) { + return reason; + } + for (const assignment of assignments) { + const separator = assignment.indexOf('='); + const name = assignment.slice(0, separator).toUpperCase(); + const value = assignment.slice(separator + 1); + if (name === 'NODE_OPTIONS' && /@oxc-node\/core/.test(value)) { + return '`NODE_OPTIONS` loads oxc-node hooks that would compete with those of `vpx`; remove it first'; + } + } + return undefined; + }, + scriptKeepReason(cwd, script) { + if (cwd === undefined || /[*?[{]/.test(script)) { + return undefined; + } + try { + const file = script.startsWith('~/') + ? path.join(os.homedir(), script.slice(2)) + : path.resolve(cwd, script); + const source = fs.readFileSync(file, 'utf8'); + if (OXNODE_PACKAGE_REFERENCE.test(source)) { + return `\`${script}\` uses a standalone oxc-node API or loader that does not configure the bundled \`vpx\` loader`; + } + } catch { + // Both runners use the same transformer; an unavailable file is not evidence + // of a runner-specific incompatibility. + } + return undefined; + }, + detectKeptUses: true, +}; + +export function rewriteOxnodeCommand(command: string, cwd?: string): ScriptRunnerCommandRewrite { + return rewriteScriptRunnerCommand(OXNODE_RUNNER, command, cwd); +} + +export function detectOxnodeProject( + rootDir: string, + packages?: readonly WorkspacePackage[], +): boolean { + return detectScriptRunnerProject(OXNODE_RUNNER, rootDir, packages); +} + +export function migrateOxnodeToVpx( + rootDir: string, + packages?: readonly WorkspacePackage[], + report?: MigrationReport, +): { changed: boolean; dependencyRemoved: boolean } { + const result = migrateScriptRunnerToVpx(OXNODE_RUNNER, rootDir, packages, report); + if (report && result.scriptsChanged) { + report.oxnodeMigrated = true; + } + return { changed: result.changed, dependencyRemoved: result.dependencyRemoved }; +} + +export async function confirmOxnodeMigration(interactive: boolean): Promise { + if (interactive) { + const confirmed = await prompts.confirm({ + message: + 'Migrate oxnode scripts to Vite+?\n ' + + styleText( + 'gray', + '`oxnode ` becomes `vpx `, and `oxnode watch` becomes `vpx --watch`. Node.js imports use `vite-plus/register`. Standalone oxc-node dependencies are removed once nothing uses them.', + ), + initialValue: true, + }); + if (prompts.isCancel(confirmed)) { + cancelAndExit(); + } + return confirmed; + } + prompts.log.info('oxnode scripts detected. Auto-migrating to Vite+...'); + return true; +} diff --git a/packages/cli/src/migration/migrator/script-runner.ts b/packages/cli/src/migration/migrator/script-runner.ts new file mode 100644 index 0000000000..f59f724612 --- /dev/null +++ b/packages/cli/src/migration/migrator/script-runner.ts @@ -0,0 +1,1048 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +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 { displayRelative } from '../../utils/path.ts'; +import { NODE_OPTIONS_WITH_VALUE, SCRIPT_EXTENSIONS } 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'; + +/** Runner-specific behavior; parsing, file edits, and dependency retention are shared. */ +export interface ScriptRunnerMigration { + name: string; + aliases?: readonly string[]; + watchSubcommand?: boolean; + dependencies: readonly string[]; + commandPattern: RegExp; + loaderPattern: RegExp; + envShebang: RegExp; + shebangPattern: RegExp; + codeReference: RegExp; + textReference: RegExp; + configValue: RegExp; + referenceFileNamePattern?: RegExp; + keepOptions: ReadonlyMap; + dropOptions?: ReadonlySet; + optionsWithValue?: ReadonlySet; + testWithoutFilesReason: string; + envKeepReason: (assignments: readonly string[]) => string | undefined; + scriptKeepReason?: (cwd: string | undefined, script: string) => string | undefined; + isLoader?: (option: string, value: string) => boolean; + /** Loader imports that can keep Node.js and use the public registration entry. */ + isRegister?: (option: string, value: string) => boolean; + registerEnvKeepReason?: (assignments: readonly string[]) => string | undefined; + registerKeepOptions?: ReadonlyMap; + registerPreloadKeepReason?: (option: string, value: string) => string | undefined; + registerInlineCodeKeepReason?: (code: string) => string | undefined; + prepareRun?: ( + args: readonly string[], + context: { + cwd: string | undefined; + environment: readonly string[]; + nodeLoader: boolean; + executable: string; + }, + ) => { + keepReason?: string; + /** Argument indices after the executable; no value removes that argument. */ + edits?: readonly { index: number; value?: string }[]; + }; + /** Report unsupported uses even when no command can be converted. */ + detectKeptUses?: boolean; +} + +const LOADER_OPTIONS = new Set([ + '--import', + '--loader', + '--experimental-loader', + '--require', + '-r', +]); +const LOADER_SETTINGS = new Set(['import', 'loader', 'require']); +const KEEP_REPL = '`vpx` does not start a TypeScript REPL'; +const KEEP_SHELL = + 'the command uses shell syntax that vp migrate does not rewrite, such as pipes, variables, or subshells'; +const KEEP_WATCH_TEST = '`vpx --watch` does not run `--test`'; +// Stands for the file that the kernel appends to a shebang's command. +const SHEBANG_ENTRY = './shebang.ts'; + +// package.json fields that name a runner without using it: dependency declarations and prose. +const IGNORED_FIELDS = new Set([ + 'bundleDependencies', + 'bundledDependencies', + 'dependencies', + 'description', + 'devDependencies', + 'keywords', + 'name', + 'optionalDependencies', + 'overrides', + 'peerDependencies', + 'peerDependenciesMeta', + 'pnpm', + 'resolutions', +]); +// Script files and framework components whose code can import a runner. +const CODE_EXTENSIONS: ReadonlySet = 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 packages without using them. +const LOCKFILES = new Set([ + 'bun.lock', + 'npm-shrinkwrap.json', + 'package-lock.json', + 'pnpm-lock.yaml', + 'yarn.lock', +]); +// The scan for remaining runner 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', +]); + +// 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 ScriptRunnerCommandRewrite { + command: string; + /** Why the runner stays in part of the command. */ + kept: string[]; +} + +interface Edit { + start: number; + end: number; + text: string; +} + +interface Rewrite { + command: string; + environment: readonly string[]; + /** The directory the next segment runs in, or undefined when it is not known. */ + cwd?: string; + edits: Edit[]; + kept: string[]; +} + +/** + * Rewrite supported runner and Node.js loader invocations while preserving shell + * syntax and arguments. Unsupported forms remain, with their reasons in `kept`. + */ +export function rewriteScriptRunnerCommand( + runner: ScriptRunnerMigration, + command: string, + cwd?: string, + environment: readonly string[] = [], +): ScriptRunnerCommandRewrite { + if (!runner.commandPattern.test(command) && !runner.envKeepReason(environment)) { + 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, environment, 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(runner, rewrite, tokens); + } + const rewritten = applyEdits(command, rewrite.edits); + if (!rewrite.kept.length && runner.commandPattern.test(rewritten)) { + rewrite.kept.push(`vp migrate does not rewrite this use of ${runner.name}`); + } + return { command: rewritten, kept: rewrite.kept }; +} + +function rewriteSegment( + runner: ScriptRunnerMigration, + 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; + } + // Runner hooks in NODE_OPTIONS must be replaced together with command changes. + // Environment assignments also reach the commands a launcher runs. + const assignments = [...rewrite.environment, ...values.slice(0, first)]; + const envReason = runner.envKeepReason(assignments); + const environment = scanEnvironmentOptions(runner, assignments); + environment.keepReason ??= runner.registerEnvKeepReason?.(assignments); + environment.keepReason ??= environment.preloads + .map((script) => runner.scriptKeepReason?.(rewrite.cwd, script)) + .find((reason) => reason !== undefined); + const editStart = rewrite.edits.length; + const keptStart = rewrite.kept.length; + const start = first + getCommandStartIndex(values.slice(first)); + const launcher = COMMAND_LAUNCHERS.get(values[start]); + if (values[start] === runner.name || runner.aliases?.includes(values[start])) { + rewriteRunner(runner, rewrite, tokens, first, start, envReason, assignments); + } else if (values[start] === 'node') { + rewriteNodeLoader(runner, rewrite, tokens, first, start, envReason, assignments, environment); + } else if (shell) { + for (const token of tokens.slice(start)) { + rewriteQuotedCommand(runner, rewrite, token, assignments); + } + } else if (launcher) { + for (let index = start + 1; index < tokens.length; index++) { + if (launcher(values[index - 1])) { + rewriteQuotedCommand(runner, rewrite, tokens[index], assignments); + } + } + } else if (values.slice(start).some((value) => runner.commandPattern.test(value))) { + rewrite.kept.push(`vp migrate does not rewrite this use of ${runner.name}`); + } + if (environment.replacements.length) { + // All children inherit this assignment. Keep it and their edits together if one + // still needs the old runner, rather than installing competing loaders. + if (environment.keepReason && rewrite.kept.length === keptStart) { + rewrite.kept.push(environment.keepReason); + } + if (rewrite.kept.length !== keptStart) { + rewrite.edits.length = editStart; + } else { + for (const replacement of environment.replacements) { + const index = replacement.index - rewrite.environment.length; + if (index >= 0) { + const token = tokens[index]; + rewrite.edits.push(literalEdit(rewrite.command, token, replacement.value)); + } + } + } + } +} + +function applyEdits(command: string, pending: readonly Edit[]): string { + let rewritten = command; + // An option normalization and a loader removal can cover the same whitespace. + const edits: Edit[] = []; + for (const edit of pending.toSorted((a, b) => b.start - a.start)) { + const previous = edits.at(-1); + if (previous?.text === '' && edit.text === '' && edit.end >= previous.start) { + previous.start = Math.min(previous.start, edit.start); + previous.end = Math.max(previous.end, edit.end); + } else { + edits.push({ ...edit }); + } + } + for (const edit of edits) { + rewritten = rewritten.slice(0, edit.start) + edit.text + rewritten.slice(edit.end); + } + return rewritten; +} + +/** Preserve the surrounding quote style when replacing one complete shell token. */ +function literalEdit(command: string, token: LiteralCommandToken, value: string): Edit { + const raw = command.slice(token.start, token.end); + const assignment = /^([A-Za-z_]\w*=)/.exec(raw); + const prefix = assignment?.[1] ?? ''; + const quote = raw[prefix.length]; + const content = value.slice(prefix.length); + let text: string; + if (quote === '"' && (prefix !== '' || raw.endsWith(quote))) { + text = `${prefix}"${content.replaceAll(/[\\"$`]/g, '\\$&')}"`; + } else if (quote === "'" && (prefix !== '' || raw.endsWith(quote))) { + text = `${prefix}'${content.replaceAll("'", "'\\''")}'`; + } else { + text = `${prefix}${/^[\w./=:-]+$/.test(content) ? content : `"${content.replaceAll(/[\\"$`]/g, '\\$&')}"`}`; + } + return { start: token.start, end: token.end, text }; +} + +interface EnvironmentOptions { + replacements: { index: number; value: string }[]; + preloads: string[]; + keepReason?: string; +} + +/** NODE_OPTIONS uses double quotes and spaces, not shell single quotes or tab separators. */ +function scanEnvironmentOptions( + runner: ScriptRunnerMigration, + assignments: readonly string[], +): EnvironmentOptions { + const result: EnvironmentOptions = { replacements: [], preloads: [] }; + for (const [index, assignment] of assignments.entries()) { + if (!/^NODE_OPTIONS=/i.test(assignment)) { + continue; + } + const value = assignment.slice(13); + const shellOnlySyntax = value.includes("'") || value.replaceAll(/"[^"]*"/g, '').includes('\t'); + const segments = shellOnlySyntax ? undefined : parseLiteralCommands(value, false); + if (!segments || segments.length !== 1) { + if (runner.commandPattern.test(value)) { + result.keepReason = + runner.envKeepReason([assignment]) ?? + `NODE_OPTIONS loads ${runner.name} hooks that vp migrate cannot replace`; + } + continue; + } + const scanned = scanOptions(runner, segments[0], 0, false); + result.preloads.push(...scanned.preloads); + result.keepReason ??= scanned.registerKeepReason; + if (scanned.loaders !== scanned.imports.length || scanned.entryIndex !== segments[0].length) { + if (runner.commandPattern.test(value)) { + result.keepReason = + runner.envKeepReason([assignment]) ?? + `NODE_OPTIONS loads ${runner.name} hooks that vp migrate cannot replace`; + } + continue; + } + if (scanned.imports.length) { + result.replacements.push({ + index, + value: `${assignment.slice(0, 13)}${applyEdits(value, importEdits(value, scanned))}`, + }); + } + } + return result; +} + +/** + * 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 ?? tokens[index].start, end: tokens[index].end, text: '' }; +} + +/** + * Why `vpx` cannot run an invocation: `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( + runner: ScriptRunnerMigration, + cwd: string | undefined, + test: boolean, + positionals: readonly string[], + preloads: readonly string[], +): string | undefined { + const [entry] = positionals; + if (entry === undefined) { + return test ? runner.testWithoutFilesReason : KEEP_REPL; + } + // An extensionless entry can be an npm package to `vpx`, rather than a script. + 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 = runner.scriptKeepReason?.(cwd, script); + if (reason) { + return reason; + } + } + return undefined; +} + +function rewriteRunner( + runner: ScriptRunnerMigration, + rewrite: Rewrite, + tokens: LiteralCommandToken[], + first: number, + start: number, + keepReason: string | undefined, + environment: readonly string[], +): void { + if (keepReason) { + rewrite.kept.push(keepReason); + return; + } + const prepared = prepareRun(runner, rewrite, tokens, start, environment, false); + if (prepared.keepReason) { + rewrite.kept.push(prepared.keepReason); + return; + } + tokens = prepared.tokens; + let index = start + 1; + const watch = runner.watchSubcommand !== false && tokens[index]?.value === 'watch'; + if (watch) { + index++; + } + const scanned = scanOptions(runner, tokens, index, true); + const reason = runReason(runner, 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' }, + ...prepared.edits, + ...scanned.removals, + ); +} + +function rewriteNodeLoader( + runner: ScriptRunnerMigration, + rewrite: Rewrite, + tokens: LiteralCommandToken[], + first: number, + start: number, + keepReason: string | undefined, + environment: readonly string[], + environmentOptions: EnvironmentOptions, +): void { + const original = scanOptions(runner, tokens, start + 1, false); + if ( + runner.isRegister && + original.loaders === original.imports.length && + (original.imports.length || environmentOptions.replacements.length) + ) { + const prepared = prepareRun(runner, rewrite, tokens, start, environment, true); + const scanned = scanOptions(runner, prepared.tokens, start + 1, false); + // Keep Node.js in charge of eval, test discovery, and native watch mode. + const scripts = scanned.inlineCode + ? [] + : prepared.tokens.slice(scanned.entryIndex).map(({ value }) => value); + const reason = + environmentOptions.keepReason ?? + prepared.keepReason ?? + scanned.registerKeepReason ?? + [...scanned.preloads, ...(scanned.test ? scripts : scripts.slice(0, 1))] + .map((script) => runner.scriptKeepReason?.(rewrite.cwd, script)) + .find((reason) => reason !== undefined); + if (reason) { + rewrite.kept.push(reason); + } else { + rewrite.edits.push(...prepared.edits, ...importEdits(rewrite.command, scanned)); + } + return; + } + if (!original.loaders) { + // An unrelated node command in the same script must not receive runner warnings. + const environmentLoader = environment.some( + (assignment) => + /^NODE_OPTIONS=/i.test(assignment) && runner.commandPattern.test(assignment.slice(13)), + ); + const reason = original.runnerLoader + ? original.keepReason + : environmentLoader + ? keepReason + : undefined; + if (reason) { + rewrite.kept.push(reason); + } + return; + } + const prepared = prepareRun(runner, rewrite, tokens, start, environment, true); + if (prepared.keepReason) { + rewrite.kept.push(prepared.keepReason); + return; + } + tokens = prepared.tokens; + const scanned = scanOptions(runner, tokens, start + 1, false); + if (!scanned.loaders) { + // A loader can also be supplied through NODE_OPTIONS. + if (keepReason) { + rewrite.kept.push(keepReason); + } + return; + } + const reason = keepReason ?? runReason(runner, 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' }, + ...prepared.edits, + ...scanned.removals, + ); +} + +/** Preserve original source ranges while normalizing runner-specific options. */ +function prepareRun( + runner: ScriptRunnerMigration, + rewrite: Rewrite, + tokens: LiteralCommandToken[], + start: number, + environment: readonly string[], + nodeLoader: boolean, +): { tokens: LiteralCommandToken[]; edits: Edit[]; keepReason?: string } { + const prepared = runner.prepareRun?.( + tokens.slice(start + 1).map(({ value }) => value), + { + cwd: rewrite.cwd, + environment, + nodeLoader, + executable: tokens[start].value, + }, + ); + const edits: Edit[] = []; + const changes = new Map(prepared?.edits?.map((edit) => [start + 1 + edit.index, edit])); + const normalized = tokens.flatMap((token, index) => { + const change = changes.get(index); + if (!change) { + return [token]; + } + if (change.value === undefined) { + edits.push(removal(tokens, index)); + return []; + } + const text = /^[\w./:=@+-]+$/.test(change.value) + ? change.value + : `'${change.value.replaceAll("'", "'\\''")}'`; + edits.push({ start: token.start, end: token.end, text }); + return [{ ...token, value: change.value }]; + }); + return { tokens: normalized, edits, keepReason: prepared?.keepReason }; +} + +/** The options before the script entry. */ +interface ScannedOptions { + /** Where the entry, or with `--test` the test files, start. */ + entryIndex: number; + /** Runner-specific options and loader flags to remove. */ + removals: Edit[]; + loaders: number; + runnerLoader: boolean; + imports: { token: LiteralCommandToken; value: string }[]; + registerKeepReason?: string; + inlineCode: boolean; + /** Local `--require` and `--import` modules, which load before the scripts. */ + preloads: string[]; + keepReason?: string; + test: boolean; + watch: boolean; +} + +function importEdits(command: string, scanned: ScannedOptions): Edit[] { + return scanned.imports.map(({ token, value }) => literalEdit(command, token, value)); +} + +/** Read options from the executable to the entry file. */ +function scanOptions( + runner: ScriptRunnerMigration, + tokens: LiteralCommandToken[], + index: number, + directRunner: boolean, +): ScannedOptions { + const scanned: ScannedOptions = { + entryIndex: tokens.length, + removals: [], + loaders: 0, + runnerLoader: false, + imports: [], + inlineCode: false, + 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 (directRunner && runner.dropOptions?.has(name)) { + // The profile declares this option redundant for vpx. + 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 && runner.loaderPattern.test(loader)) { + scanned.runnerLoader = true; + if (runner.isLoader && !runner.isLoader(name, loader)) { + scanned.keepReason ??= `vp migrate does not rewrite ${name} ${loader}`; + scanned.registerKeepReason ??= scanned.keepReason; + if (!inline) { + index++; + } + continue; + } + // The old loader would compete with the hooks of `vpx`. + scanned.removals.push({ + ...removal(tokens, index), + end: tokens[inline ? index : index + 1].end, + }); + scanned.loaders++; + if (runner.isRegister?.(name, loader)) { + scanned.imports.push({ + token: tokens[inline ? index : index + 1], + value: inline + ? `${value.slice(0, value.indexOf('=') + 1)}vite-plus/register` + : 'vite-plus/register', + }); + } + } else if (loader !== undefined) { + scanned.registerKeepReason ??= runner.registerPreloadKeepReason?.(name, loader); + if (loader.startsWith('.') || path.isAbsolute(loader)) { + scanned.preloads.push(loader); + } + } + if (!inline) { + index++; + } + continue; + } + scanned.keepReason ??= runner.keepOptions.get(name); + scanned.registerKeepReason ??= runner.registerKeepOptions?.get(name); + if (runner.registerInlineCodeKeepReason) { + const code = ['-e', '-p', '-pe', '--eval', '--print'].includes(name) + ? inline + ? value.slice(value.indexOf('=') + 1) + : tokens[index + 1]?.value + : /^-(?:pe|ep|e|p)(.+)$/.exec(value)?.[1]; + if (code !== undefined) { + scanned.registerKeepReason ??= runner.registerInlineCodeKeepReason(code); + } + } + scanned.test ||= name === '--test'; + scanned.watch ||= name === '--watch'; + scanned.inlineCode ||= ['-e', '-p', '-pe', '--eval', '--print', '--run'].includes(name); + if ( + !inline && + (NODE_OPTIONS_WITH_VALUE.has(name) || (directRunner && runner.optionsWithValue?.has(name))) + ) { + index++; + } + } + return scanned; +} + +/** Why `vpx` cannot run what the scanned options and the scripts after them run. */ +function runReason( + runner: ScriptRunnerMigration, + 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( + runner, + cwd, + scanned.test, + tokens.slice(scanned.entryIndex).map(({ value }) => value), + scanned.preloads, + ); +} + +/** Rewrite a quoted launcher command with the environment it inherits. */ +function rewriteQuotedCommand( + runner: ScriptRunnerMigration, + rewrite: Rewrite, + token: LiteralCommandToken, + environment: readonly string[], +): 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 = rewriteScriptRunnerCommand(runner, token.value, rewrite.cwd, environment); + rewrite.kept.push(...inner.kept); + if (inner.command !== token.value) { + rewrite.edits.push(literalEdit(rewrite.command, token, inner.command)); + } +} + +/** 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 a runner outside devDependencies keeps it: published code or + * production scripts can need it, and `vpx` comes from the vite-plus devDependency. + */ +function ownsRunnerAtRuntime(runner: ScriptRunnerMigration, pkg: DependencyBag): boolean { + return runner.dependencies.some( + (dependency) => + pkg.dependencies?.[dependency] !== undefined || + pkg.optionalDependencies?.[dependency] !== undefined || + pkg.peerDependencies?.[dependency] !== undefined, + ); +} + +function packageDirs(rootDir: string, packages?: readonly WorkspacePackage[]): string[] { + return [rootDir, ...(packages ?? []).map((pkg) => path.join(rootDir, pkg.path))]; +} + +/** + * Detect supported invocations, or retained uses when the profile requests warnings. + */ +export function detectScriptRunnerProject( + runner: ScriptRunnerMigration, + rootDir: string, + packages?: readonly WorkspacePackage[], +): boolean { + return packageDirs(rootDir, packages).some((dir) => { + const pkg = readPackageJsonIfExists(path.join(dir, 'package.json')); + if (!pkg || (!runner.detectKeptUses && ownsRunnerAtRuntime(runner, pkg))) { + return false; + } + const commands = [ + ...packageCommands(pkg), + ...lintStagedJsonFiles(dir).flatMap((file) => recordCommands(readJsonFile(file))), + ]; + return ( + commands.some((command) => + runner.detectKeptUses + ? runner.commandPattern.test(command) + : rewriteScriptRunnerCommand(runner, command, dir).command !== command, + ) || + (runner.detectKeptUses && findRunnerUse(runner, dir) !== undefined) || + sourceTreeMatches(dir, (content, file) => { + const result = rewriteRunnerShebang(runner, content, file); + return result !== undefined && 'content' in result; + }) + ); + }); +} + +/** Rewrite each command of a scripts or lint-staged record; returns whether one changed. */ +function rewriteCommandRecord( + runner: ScriptRunnerMigration, + 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 = rewriteScriptRunnerCommand(runner, command, cwd); + for (const reason of result.kept) { + warnMigration(`Kept ${runner.name} 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 runner shebang, or its retention reason. + */ +function rewriteRunnerShebang( + runner: ScriptRunnerMigration, + content: string, + file: string, +): { content: string } | { reason: string } | undefined { + const match = runner.envShebang.exec(content); + if (!match) { + return undefined; + } + const [line, prefix, command] = match; + // The kernel runs the shebang's command with the file appended. + const result = rewriteScriptRunnerCommand(runner, `${command.trimEnd()} ${SHEBANG_ENTRY}`); + const reason = + result.kept[0] ?? runner.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 matching env shebangs in a package's scripts at `vpx`. */ +function rewriteRunnerShebangs( + runner: ScriptRunnerMigration, + 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 = rewriteRunnerShebang(runner, content, file); + if (result && 'reason' in result) { + warnMigration( + `Kept ${runner.name} in the shebang of ${displayRelative(file)}: ${result.reason}.`, + report, + ); + } else if (result) { + fs.writeFileSync(file, result.content); + changed = true; + } + return false; + }); + return changed; +} + +/** + * Find a remaining use, including nested packages and build output that can resolve + * the package's runner dependencies. + */ +function findRunnerUse(runner: ScriptRunnerMigration, dir: string): string | undefined { + let found: string | undefined; + sourceTreeMatches( + dir, + (content, file) => { + if (LOCKFILES.has(path.basename(file))) { + return false; + } + const uses = fileUsesRunner(runner, content, file); + if (uses) { + found = `${displayRelative(file)} still uses ${runner.name}`; + } + return uses; + }, + { + crossPackageBoundaries: true, + extensions: REFERENCE_EXTENSIONS, + fileNamePattern: runner.referenceFileNamePattern, + 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) && + jsonUsesRunner(runner, value, ['imports', 'lint-staged', 'scripts'].includes(key)), + )?.[0]; + if (field !== undefined) { + found = `\`${field}\` in ${displayRelative(path.join(packageDir, 'package.json'))} still uses ${runner.name}`; + } + return field !== undefined; + }, + skipDirs: USE_SCAN_SKIP_DIRS, + }, + ); + return found; +} + +/** Whether a file still runs, imports, or loads the runner. */ +function fileUsesRunner(runner: ScriptRunnerMigration, content: string, file: string): boolean { + if (runner.shebangPattern.test(content)) { + return true; + } + // lint-staged appends staged files to bare runner commands. + const name = path.basename(file); + if (runner.referenceFileNamePattern?.test(name)) { + return runner.textReference.test(content); + } + if ( + LINT_STAGED_JSON_CONFIG_FILES.some((config) => config === name) && + jsonUsesRunner(runner, parseJsonc(content), true) + ) { + return true; + } + const extension = path.extname(file); + if (extension === '.json' || extension === '.jsonc') { + return jsonUsesRunner(runner, parseJsonc(content), false); + } + if (CODE_EXTENSIONS.has(extension)) { + return runner.codeReference.test(content); + } + // An extensionless file can be JavaScript, such as an executable, as well as shell. + return ( + runner.textReference.test(content) || (extension === '' && runner.codeReference.test(content)) + ); +} + +/** Recognize command strings and loader references in structured configuration. */ +function jsonUsesRunner(runner: ScriptRunnerMigration, value: unknown, commands: boolean): boolean { + if (typeof value === 'string') { + return (commands && runner.commandPattern.test(value)) || runner.configValue.test(value); + } + if (Array.isArray(value)) { + return value.some( + (item, index) => + jsonUsesRunner(runner, item, commands) || + (typeof item === 'string' && + LOADER_OPTIONS.has(item) && + runner.loaderPattern.test(String(value[index + 1]))), + ); + } + return ( + isPlainRecord(value) && + Object.entries(value).some( + ([key, item]) => + jsonUsesRunner(runner, item, commands) || + (LOADER_SETTINGS.has(key) && + [item].flat().some((loader) => runner.loaderPattern.test(String(loader)))), + ) + ); +} + +/** Rewrite known command surfaces, then remove dependencies with no remaining uses. */ +export function migrateScriptRunnerToVpx( + runner: ScriptRunnerMigration, + rootDir: string, + packages?: readonly WorkspacePackage[], + report?: MigrationReport, +): { changed: boolean; dependencyRemoved: boolean; scriptsChanged: 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 (ownsRunnerAtRuntime(runner, pkg)) { + if (packageCommands(pkg).some((c) => runner.commandPattern.test(c))) { + warnMigration( + `Kept ${runner.name} 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( + runner, + 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(runner, config, displayRelative(file), dir, report); + changed ||= edited; + return edited ? config : undefined; + }); + } + if (rewriteRunnerShebangs(runner, dir, report)) { + changed = true; + } + } + + // Remove dependencies after every package is rewritten: the root 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'); + const devDependencies = readPackageJsonIfExists(packageJsonPath)?.devDependencies; + if (!runner.dependencies.some((dependency) => devDependencies?.[dependency] !== undefined)) { + continue; + } + const use = findRunnerUse(runner, dir); + if (use) { + warnMigration( + `Kept the ${runner.dependencies.join(' / ')} devDependency in ${displayRelative(packageJsonPath)}: ${use}.`, + report, + ); + continue; + } + editJsonFile(packageJsonPath, (json) => { + for (const dependency of runner.dependencies) { + delete json.devDependencies![dependency]; + } + return json; + }); + dependencyRemoved = true; + } + return { changed: changed || dependencyRemoved, dependencyRemoved, scriptsChanged: changed }; +} diff --git a/packages/cli/src/migration/migrator/source-scan.ts b/packages/cli/src/migration/migrator/source-scan.ts index 2b2f9f933b..5f56f0731b 100644 --- a/packages/cli/src/migration/migrator/source-scan.ts +++ b/packages/cli/src/migration/migrator/source-scan.ts @@ -201,6 +201,8 @@ export function sourceTreeMatches( // package imports/scripts, even when they are not workspace members. crossPackageBoundaries?: boolean; extensions?: ReadonlySet; + /** Additional text files whose names do not have a known source extension. */ + fileNamePattern?: RegExp; includePackageReferences?: boolean; includeExtensionless?: boolean; matchesPackage?: (projectPath: string, pkg: DependencyBag) => boolean; @@ -238,6 +240,7 @@ export function sourceTreeMatches( entry.isFile() && !['.pnp.cjs', '.pnp.loader.mjs'].includes(entry.name) && (extensions.has(path.extname(entry.name)) || + options.fileNamePattern?.test(entry.name) || (options.includeExtensionless && path.extname(entry.name) === '') || (options.includePackageReferences && entry.name === 'package.json')) ) { diff --git a/packages/cli/src/migration/migrator/tsx.ts b/packages/cli/src/migration/migrator/tsx.ts index 4649260abf..b37b9941b3 100644 --- a/packages/cli/src/migration/migrator/tsx.ts +++ b/packages/cli/src/migration/migrator/tsx.ts @@ -4,31 +4,19 @@ 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 { SCRIPT_EXTENSIONS, WATCH_OPTIONS_WITH_VALUE } from '../../vpx-script.ts'; import { - NODE_OPTIONS_WITH_VALUE, - SCRIPT_EXTENSIONS, - WATCH_OPTIONS_WITH_VALUE, -} from '../../vpx-script.ts'; -import { sourceTreeMatches } from '../migrator.ts'; + detectScriptRunnerProject, + migrateScriptRunnerToVpx, + rewriteScriptRunnerCommand, + type ScriptRunnerCommandRewrite, + type ScriptRunnerMigration, +} 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 @@ -36,30 +24,15 @@ import { 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_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`. @@ -78,8 +51,6 @@ const KEEP_OPTIONS = new Map([ // 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 @@ -120,65 +91,6 @@ const TSX_CONFIG_VALUE = new RegExp( ].join('|'), 'i', ); -// package.json fields that name tsx without running it: dependency declarations and prose. -const IGNORED_FIELDS = new Set([ - 'bundleDependencies', - 'bundledDependencies', - 'dependencies', - 'description', - 'devDependencies', - 'keywords', - 'name', - 'optionalDependencies', - 'overrides', - 'peerDependencies', - 'peerDependenciesMeta', - 'pnpm', - 'resolutions', -]); -// Script files and framework components whose code can import tsx. -const CODE_EXTENSIONS: ReadonlySet = 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. @@ -205,215 +117,45 @@ const TYPESCRIPT_EXTENSIONS: Record = { 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; -} +const TSX_RUNNER: ScriptRunnerMigration = { + name: 'tsx', + dependencies: ['tsx'], + commandPattern: TSX_COMMAND, + loaderPattern: TSX_LOADER, + isRegister: (option, value) => option === '--import' && value === 'tsx', + envShebang: TSX_ENV_SHEBANG, + shebangPattern: TSX_SHEBANG, + codeReference: TSX_CODE_REFERENCE, + textReference: TSX_TEXT_REFERENCE, + configValue: TSX_CONFIG_VALUE, + keepOptions: KEEP_OPTIONS, + dropOptions: new Set(['--no-cache']), + optionsWithValue: new Set([...WATCH_OPTIONS_WITH_VALUE, '--tsconfig']), + testWithoutFilesReason: KEEP_TEST_GLOBS, + envKeepReason, + scriptKeepReason, +}; -interface Rewrite { - command: string; - /** The directory the next segment runs in, or undefined when it is not known. */ - cwd?: string; - edits: Edit[]; - kept: string[]; -} +export type TsxCommandRewrite = ScriptRunnerCommandRewrite; -/** - * 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` keeps Node.js with `vite-plus/register`. 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 { - return rewriteCommand(command, cwd, []); + return rewriteScriptRunnerCommand(TSX_RUNNER, command, cwd); } -function rewriteCommand( - command: string, - cwd: string | undefined, - inherited: readonly string[], -): TsxCommandRewrite { - if (!TSX_COMMAND.test(command) && !envKeepReason(inherited)) { - 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, inherited); - } - const rewritten = applyEdits(command, rewrite.edits); - 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[], - inherited: readonly string[], -): 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 hooks loaded through NODE_OPTIONS would compete with those of `vpx`. - // Environment assignments also reach the commands a launcher runs. - const assignments = [...inherited, ...values.slice(0, first)]; - const envReason = envKeepReason(assignments); - const environment = scanEnvironmentOptions(assignments); - environment.keepReason ??= environment.preloads - .map((script) => scriptKeepReason(rewrite.cwd, script)) - .find((reason) => reason !== undefined); - const editStart = rewrite.edits.length; - const keptStart = rewrite.kept.length; - 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, environment); - } else if (shell) { - for (const token of tokens.slice(start)) { - rewriteQuotedCommand(rewrite, token, assignments); - } - } else if (launcher) { - for (let index = start + 1; index < tokens.length; index++) { - if (launcher(values[index - 1])) { - rewriteQuotedCommand(rewrite, tokens[index], assignments); - } - } - } else if (values.slice(start).some((value) => TSX_COMMAND.test(value))) { - rewrite.kept.push(KEEP_OTHER); - } - if (environment.replacements.length) { - // All children inherit this assignment. Keep it and their edits together if one - // still needs tsx, rather than installing both loaders in the same process. - if (environment.keepReason && rewrite.kept.length === keptStart) { - rewrite.kept.push(environment.keepReason); - } - if (rewrite.kept.length !== keptStart) { - rewrite.edits.length = editStart; - } else { - for (const replacement of environment.replacements) { - const index = replacement.index - inherited.length; - if (index >= 0) { - const token = tokens[index]; - rewrite.edits.push(literalEdit(rewrite.command, token, replacement.value)); - } - } - } - } -} - -function applyEdits(command: string, edits: readonly Edit[]): string { - for (const edit of edits.toSorted((a, b) => b.start - a.start)) { - command = command.slice(0, edit.start) + edit.text + command.slice(edit.end); - } - return command; -} - -/** Preserve the surrounding quote style when replacing one complete shell token. */ -function literalEdit(command: string, token: LiteralCommandToken, value: string): Edit { - const raw = command.slice(token.start, token.end); - const assignment = /^([A-Za-z_]\w*=)/.exec(raw); - const prefix = assignment?.[1] ?? ''; - const quote = raw[prefix.length]; - const content = value.slice(prefix.length); - let text: string; - if (quote === '"' && (prefix !== '' || raw.endsWith(quote))) { - text = `${prefix}"${content.replaceAll(/[\\"$`]/g, '\\$&')}"`; - } else if (quote === "'" && (prefix !== '' || raw.endsWith(quote))) { - text = `${prefix}'${content.replaceAll("'", "'\\''")}'`; - } else { - text = `${prefix}${/^[\w./=:-]+$/.test(content) ? content : `"${content.replaceAll(/[\\"$`]/g, '\\$&')}"`}`; - } - return { start: token.start, end: token.end, text }; -} - -interface EnvironmentOptions { - replacements: { index: number; value: string }[]; - preloads: string[]; - keepReason?: string; +export function detectTsxProject(rootDir: string, packages?: readonly WorkspacePackage[]): boolean { + return detectScriptRunnerProject(TSX_RUNNER, rootDir, packages); } -/** NODE_OPTIONS uses double quotes and spaces, not shell single quotes or tab separators. */ -function scanEnvironmentOptions(assignments: readonly string[]): EnvironmentOptions { - const result: EnvironmentOptions = { replacements: [], preloads: [] }; - for (const [index, assignment] of assignments.entries()) { - if (!/^NODE_OPTIONS=/i.test(assignment)) { - continue; - } - const value = assignment.slice(13); - const shellOnlySyntax = value.includes("'") || value.replaceAll(/"[^"]*"/g, '').includes('\t'); - const segments = shellOnlySyntax ? undefined : parseLiteralCommands(value, false); - if (!segments || segments.length !== 1) { - if (TSX_COMMAND.test(value)) { - result.keepReason = KEEP_NODE_OPTIONS; - } - continue; - } - const scanned = scanOptions(segments[0], 0, false); - result.preloads.push(...scanned.preloads); - if (scanned.loaders !== scanned.imports.length || scanned.entryIndex !== segments[0].length) { - if (TSX_COMMAND.test(value)) { - result.keepReason = KEEP_NODE_OPTIONS; - } - continue; - } - if (scanned.imports.length) { - result.replacements.push({ - index, - value: `${assignment.slice(0, 13)}${applyEdits(value, importEdits(value, scanned))}`, - }); - } +export function migrateTsxToVpx( + rootDir: string, + packages?: readonly WorkspacePackage[], + report?: MigrationReport, +): { changed: boolean; dependencyRemoved: boolean } { + const result = migrateScriptRunnerToVpx(TSX_RUNNER, rootDir, packages, report); + if (report && result.scriptsChanged) { + report.tsxMigrated = true; } - return result; + return { changed: result.changed, dependencyRemoved: result.dependencyRemoved }; } /** @@ -429,20 +171,6 @@ function envKeepReason(assignments: readonly string[]): string | undefined { 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 ?? tokens[index].start, 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` @@ -522,550 +250,6 @@ function usesCommonJsGlobals(code: string): boolean { ); } -/** - * 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, - environment: EnvironmentOptions, -): void { - const scanned = scanOptions(tokens, start + 1, false); - if ( - scanned.loaders === scanned.imports.length && - (scanned.imports.length || environment.replacements.length) - ) { - // Node.js keeps control of eval, test discovery, and native watch mode. Only the - // compiler checks apply, including other preloads named in NODE_OPTIONS. - const scripts = scanned.inlineCode - ? [] - : tokens.slice(scanned.entryIndex).map(({ value }) => value); - const reason = - environment.keepReason ?? - [...scanned.preloads, ...(scanned.test ? scripts : scripts.slice(0, 1))] - .map((script) => scriptKeepReason(rewrite.cwd, script)) - .find((reason) => reason !== undefined); - if (reason) { - rewrite.kept.push(reason); - } else { - rewrite.edits.push(...importEdits(rewrite.command, scanned)); - } - return; - } - 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; - /** Plain tsx --import arguments to replace without changing the Node.js command. */ - imports: { token: LiteralCommandToken; value: string }[]; - /** Local `--require` and `--import` modules, which load before the scripts. */ - preloads: string[]; - keepReason?: string; - test: boolean; - watch: boolean; - inlineCode: boolean; -} - -function importEdits(command: string, scanned: ScannedOptions): Edit[] { - return scanned.imports.map(({ token, value }) => literalEdit(command, token, value)); -} - -/** 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, - imports: [], - preloads: [], - test: false, - watch: false, - inlineCode: 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++; - if (name === '--import' && loader === 'tsx') { - scanned.imports.push({ - token: tokens[inline ? index : index + 1], - value: inline - ? `${value.slice(0, value.indexOf('=') + 1)}vite-plus/register` - : 'vite-plus/register', - }); - } - } 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'; - scanned.inlineCode ||= ['-e', '-p', '-pe', '--eval', '--print', '--run'].includes(name); - 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, including the environment it inherits. - */ -function rewriteQuotedCommand( - rewrite: Rewrite, - token: LiteralCommandToken, - inherited: readonly string[], -): 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 = rewriteCommand(token.value, rewrite.cwd, inherited); - rewrite.kept.push(...inner.kept); - if (inner.command === token.value) { - return; - } - rewrite.edits.push(literalEdit(rewrite.command, token, inner.command)); -} - -/** 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({ diff --git a/packages/cli/src/migration/report.ts b/packages/cli/src/migration/report.ts index 9893f56790..c0cb3ef0fc 100644 --- a/packages/cli/src/migration/report.ts +++ b/packages/cli/src/migration/report.ts @@ -26,6 +26,7 @@ export interface MigrationReport { prettierMigrated: boolean; tsupMigrated: boolean; tsxMigrated: boolean; + oxnodeMigrated: boolean; nodeVersionFileMigrated: boolean; setupVpVersionUpdatedFileCount: number; gitHooksConfigured: boolean; @@ -52,6 +53,7 @@ export function createMigrationReport(): MigrationReport { eslintMigrated: false, tsupMigrated: false, tsxMigrated: false, + oxnodeMigrated: false, prettierMigrated: false, nodeVersionFileMigrated: false, setupVpVersionUpdatedFileCount: 0,